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.
1665 lines
64 KiB
TypeScript
1665 lines
64 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter, usePathname } from 'next/navigation'
|
||
import Link from 'next/link'
|
||
import UploadModal from '@/components/UploadModal'
|
||
import RankingPanel from '@/components/RankingPanel'
|
||
import MaterialLibraryAccessShell from '@/components/material-library/MaterialLibraryAccessShell'
|
||
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 } 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'
|
||
|
||
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 MaterialLibraryPage() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const pathname = usePathname()
|
||
const [materials, setMaterials] = useState<Material[]>([])
|
||
const [loading, setLoading] = 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 transcodeStartTimes = useRef<Map<string, number>>(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 replacingMaterialsRequestIdRef = useRef<number | null>(null)
|
||
const facetsRequestIdRef = useRef(0)
|
||
const facetsAbortRef = useRef<AbortController | null>(null)
|
||
|
||
// ✅ 清理转码Map的函数(直接操作Map,避免循环依赖)
|
||
const cleanupTranscodeMaps = useCallback(() => {
|
||
const now = Date.now()
|
||
const MAX_AGE = 30 * 60 * 1000 // 30分钟
|
||
let cleanedCount = 0
|
||
|
||
// 清理超过30分钟的条目
|
||
transcodeStartTimes.current.forEach((startTime, materialId) => {
|
||
if (now - startTime > MAX_AGE) {
|
||
const timer = transcodePollTimers.current.get(materialId)
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
transcodePollTimers.current.delete(materialId)
|
||
}
|
||
transcodePollingFlags.current.delete(materialId)
|
||
transcodeStartTimes.current.delete(materialId)
|
||
cleanedCount++
|
||
}
|
||
})
|
||
|
||
// 清理不在当前素材列表中的条目
|
||
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)
|
||
transcodeStartTimes.current.delete(materialId)
|
||
cleanedCount++
|
||
}
|
||
})
|
||
|
||
if (cleanedCount > 0) {
|
||
console.log(`[TranscodePoll] Cleaned up ${cleanedCount} old entries. Current size: ${transcodePollTimers.current.size}`)
|
||
}
|
||
}, [materials])
|
||
|
||
// ✅ 定期清理转码Map(每10分钟)
|
||
useEffect(() => {
|
||
const interval = setInterval(() => {
|
||
if (transcodePollTimers.current.size > 0) {
|
||
console.log(`[TranscodePoll] Periodic cleanup check. Current size: ${transcodePollTimers.current.size}`)
|
||
cleanupTranscodeMaps()
|
||
}
|
||
}, 10 * 60 * 1000) // 10分钟
|
||
|
||
return () => clearInterval(interval)
|
||
}, [cleanupTranscodeMaps])
|
||
|
||
// 音频播放状态
|
||
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, setThemeColor] = useState('#1f2937') // 主题颜色
|
||
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())
|
||
}
|
||
|
||
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) => {
|
||
if (value !== selectedCategory) {
|
||
setSelectedScene('')
|
||
}
|
||
setSelectedCategory(value)
|
||
}, [selectedCategory])
|
||
|
||
// 移除页面卸载时保存状态的逻辑(避免滚动问题)
|
||
|
||
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(() => {
|
||
fetchThemeColor()
|
||
}, [])
|
||
|
||
// 加载当前用户可见的素材类型
|
||
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 !== '/assets/material-library') 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 === '/assets/material-library') {
|
||
// 延迟重置,确保恢复逻辑已执行
|
||
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 fetchThemeColor = async () => {
|
||
try {
|
||
const response = await fetch('/api/system-settings')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setThemeColor(data.headerBgColor || '#1f2937')
|
||
}
|
||
} catch (error) {
|
||
console.error('获取主题颜色失败:', error)
|
||
}
|
||
}
|
||
|
||
// 移除筛选条件保存逻辑(避免滚动问题)
|
||
|
||
// 获取素材列表(支持分页和追加模式)
|
||
const fetchMaterials = useCallback(
|
||
async (page = 1, append = false) => {
|
||
// 筛选刷新正在替换第一页时,禁止无限滚动请求抢占同一个请求通道。
|
||
// 否则追加请求会取消筛选请求,却不会负责关闭筛选遮罩,导致页面永久转圈。
|
||
if (append && replacingMaterialsRequestIdRef.current !== null) return 0
|
||
|
||
const requestId = ++materialsRequestIdRef.current
|
||
if (!append) replacingMaterialsRequestIdRef.current = requestId
|
||
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(Math.min(24, materialsList.length))
|
||
}
|
||
|
||
const appendedCount = append ? appendedItems.length : materialsList.length
|
||
|
||
setCurrentPage(page)
|
||
const totalPages = data.totalPages || 1
|
||
setHasMore(appendedCount > 0 && page < totalPages)
|
||
setCurrentMaterialsTotalCount(data.total ?? data.totalCount ?? materialsList.length)
|
||
|
||
// 对需要转码的视频素材启动轮询
|
||
appendedItems.forEach((material) => {
|
||
if (isVideoMaterial(material.materialType) && material.videoSourceUrl) {
|
||
startTranscodePoll(material, 0)
|
||
}
|
||
})
|
||
|
||
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) {
|
||
replacingMaterialsRequestIdRef.current = null
|
||
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)
|
||
transcodeStartTimes.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 === 400) {
|
||
stopTranscodePoll(materialId)
|
||
return
|
||
}
|
||
throw new Error(`status ${response.status}: ${errorData.error || 'Unknown error'}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
const isProcessing = data.queue?.processingIds?.includes(materialId) ?? false
|
||
|
||
// 记录开始处理时间
|
||
if (isProcessing && !transcodeStartTimes.current.get(materialId)) {
|
||
transcodeStartTimes.current.set(materialId, Date.now())
|
||
}
|
||
if (!isProcessing && transcodeStartTimes.current.get(materialId)) {
|
||
transcodeStartTimes.current.delete(materialId)
|
||
}
|
||
|
||
// 更新素材状态
|
||
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)
|
||
}
|
||
|
||
// 检查队列是否为空且状态为processing(可能是异常情况,尝试重试)
|
||
const queueEmpty = (data.queue?.processingIds?.length ?? 0) === 0 && (data.queue?.waitingTotal ?? 0) === 0
|
||
if (data.status === 'processing' && queueEmpty) {
|
||
const retryCount = transcodeStartTimes.current.get(materialId) ? 1 : 0
|
||
if (retryCount < 2) {
|
||
// 静默重试
|
||
try {
|
||
await fetch('/api/materials/transcode', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ materialId, forceRetry: true }),
|
||
})
|
||
} catch (e) {
|
||
// 静默失败
|
||
}
|
||
}
|
||
}
|
||
|
||
// 根据状态决定是否继续轮询
|
||
if (data.status === 'done') {
|
||
stopTranscodePoll(materialId)
|
||
setTimeout(() => {
|
||
fetchMaterialsRef.current?.(1, false)
|
||
}, 500)
|
||
} else if (data.status === 'failed') {
|
||
stopTranscodePoll(materialId)
|
||
} else {
|
||
// 继续轮询,5秒后再次检查
|
||
const timer = setTimeout(poll, 5000)
|
||
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)
|
||
}, [stopTranscodePoll])
|
||
|
||
// 组件卸载时清理所有转码轮询
|
||
useEffect(() => {
|
||
return () => {
|
||
transcodePollTimers.current.forEach((timer) => {
|
||
clearTimeout(timer)
|
||
})
|
||
transcodePollTimers.current.clear()
|
||
transcodePollingFlags.current.clear()
|
||
transcodeStartTimes.current.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 || isTransitionLoading || replacingMaterialsRequestIdRef.current !== null) return
|
||
|
||
// 如果当前显示的数量小于已加载的素材数量,直接增加显示数量
|
||
if (displayedCount < materials.length) {
|
||
setIsLoadingMore(true)
|
||
setDisplayedCount((prev) => Math.min(prev + 24, materials.length))
|
||
setIsLoadingMore(false)
|
||
return
|
||
}
|
||
|
||
// 如果没有更多数据,不再加载
|
||
if (!hasMore) return
|
||
|
||
// 加载下一页数据
|
||
setIsLoadingMore(true)
|
||
const appended = await fetchMaterials(currentPage + 1, true)
|
||
if (appended > 0) {
|
||
setDisplayedCount((prev) => Math.min(prev + 24, materials.length + appended))
|
||
}
|
||
setIsLoadingMore(false)
|
||
}, [isLoadingMore, isTransitionLoading, 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
|
||
|
||
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 || {}) })
|
||
setMaterialTypeCounts(
|
||
Object.fromEntries(
|
||
((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]),
|
||
),
|
||
)
|
||
}
|
||
} 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 (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)
|
||
}
|
||
}
|
||
|
||
if (status === 'loading' || loading) {
|
||
return (
|
||
<div className="flex min-h-screen items-center justify-center bg-[#edf3fa]">
|
||
<div className="text-xl text-[#66758b]">加载中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!session) {
|
||
return null
|
||
}
|
||
|
||
const canAccessBrandCenter = session.user.canAccessBrandCenter === true
|
||
|
||
return (
|
||
<MaterialLibraryAccessShell canAccessBrandCenter={canAccessBrandCenter}>
|
||
<div className="department-material-light min-h-[calc(100vh-72px)] bg-[#f2f4f7] text-[#101828]">
|
||
|
||
{/* 收藏操作提示气泡 */}
|
||
{favoriteNotification && (
|
||
<div className="fixed top-20 right-4 z-50 px-6 py-3 bg-gray-900 dark:bg-gray-800 text-white rounded-lg shadow-lg animate-fade-in">
|
||
{favoriteNotification}
|
||
</div>
|
||
)}
|
||
|
||
{/* 内网切换提示已移除:主站统一使用 https://sc.jiansun.vip,旧域名 3d.yydd.ai 仅保留跳转 */}
|
||
|
||
<RankingPanel appearance="light" safeLeft={canAccessBrandCenter ? 272 : 0} dockRight />
|
||
|
||
<main className="container mx-auto max-w-7xl px-4 py-6">
|
||
{/* Header Section */}
|
||
<div className="mb-8">
|
||
<div className="flex justify-end items-center mb-6">
|
||
<button
|
||
onClick={() => setShowUploadModal(true)}
|
||
className="rounded-xl bg-[#247fd3] px-6 py-3 font-medium text-white shadow-[0_8px_22px_rgba(36,127,211,0.20)] transition-colors hover:bg-[#176fc1]"
|
||
>
|
||
<span className="mr-2">+</span>
|
||
上传素材
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search Bar - 实时搜索 */}
|
||
<SearchBar
|
||
value={searchKeyword}
|
||
onChange={handleSearchChange}
|
||
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
|
||
isSearching={isSearchPending}
|
||
/>
|
||
|
||
{/* 筛选面板组件 */}
|
||
<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}
|
||
/>
|
||
|
||
{/* Active Filters Display */}
|
||
{(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && (
|
||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-apple p-3 mb-4">
|
||
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 flex-wrap">
|
||
<span className="font-medium text-blue-600 dark:text-blue-400">当前筛选:</span>
|
||
{searchKeyword && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
搜索: {searchKeyword}
|
||
<button
|
||
onClick={() => setSearchKeyword('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
{selectedMaterialType && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
素材类型: {selectedMaterialType}
|
||
<button
|
||
onClick={() => setSelectedMaterialType('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
{selectedCategory && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
店铺: {selectedCategory}
|
||
<button
|
||
onClick={() => handleCategoryChange('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
{selectedScene && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
场景类别: {selectedScene}
|
||
<button
|
||
onClick={() => setSelectedScene('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
{selectedPlatform && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
平台分类: {selectedPlatform}
|
||
<button
|
||
onClick={() => setSelectedPlatform('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
{selectedProductCategory && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
产品类别: {selectedProductCategory}
|
||
<button
|
||
onClick={() => setSelectedProductCategory('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
{selectedMonth && (
|
||
<span className="inline-flex items-center gap-1 px-3 py-1 bg-gray-100 dark:bg-gray-800 rounded-full">
|
||
月份: {months.find(m => m.value === selectedMonth)?.label || selectedMonth}
|
||
<button
|
||
onClick={() => setSelectedMonth('')}
|
||
className="text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
setSearchKeyword('')
|
||
setSelectedMaterialType('')
|
||
setSelectedCategory('')
|
||
setSelectedScene('')
|
||
setSelectedPlatform('')
|
||
setSelectedProductCategory('')
|
||
setSelectedMonth('')
|
||
}}
|
||
className="text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 underline ml-2 font-medium"
|
||
>
|
||
清除所有筛选
|
||
</button>
|
||
</div>
|
||
</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}
|
||
disabled={isTransitionLoading}
|
||
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 dark:bg-gray-800 rounded-xl p-6 max-w-lg w-full mx-4 shadow-2xl"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">分享链接</h3>
|
||
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded-lg mb-4 break-all text-sm text-gray-800 dark:text-gray-200">
|
||
{shareUrl}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={copyShareUrl}
|
||
className="flex-1 text-white px-4 py-2 rounded-lg transition-all hover:opacity-90"
|
||
style={{ backgroundColor: themeColor }}
|
||
>
|
||
复制链接
|
||
</button>
|
||
<button
|
||
onClick={() => setShowShareModal(false)}
|
||
className="flex-1 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-800 dark:text-white px-4 py-2 rounded-lg transition-colors"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI聊天助手 */}
|
||
<AIChatWidget />
|
||
</div>
|
||
</MaterialLibraryAccessShell>
|
||
)
|
||
}
|