1865 lines
74 KiB
TypeScript
1865 lines
74 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useRef, useCallback, useDeferredValue } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter, usePathname } from 'next/navigation'
|
||
import UploadModal from '@/components/UploadModal'
|
||
import Header from '@/components/Header'
|
||
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 { 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
|
||
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 // 视频处理状态
|
||
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
|
||
}
|
||
}
|
||
|
||
export default function Home() {
|
||
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 [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选
|
||
const [categories, setCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||
const [scenes, setScenes] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||
const [platforms, setPlatforms] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||
const [productCategories, setProductCategories] = useState<Array<{ id: string; name: string; count: number }>>([])
|
||
const [isFilterCollapsed, setIsFilterCollapsed] = useState(true) // 筛选区域折叠状态,默认折叠
|
||
// 素材类型列表(已分类排序)
|
||
const DEFAULT_MATERIAL_TYPES = [
|
||
// 3D相关
|
||
'3D场景',
|
||
'材质',
|
||
'标准模型',
|
||
'标准饰品',
|
||
// 图片相关
|
||
'详情模板',
|
||
'模特',
|
||
'实拍图片',
|
||
'买家秀图片',
|
||
// 视频相关
|
||
'主图视频',
|
||
'安装视频',
|
||
'种草视频',
|
||
// 模板和种草
|
||
'主图模版',
|
||
'SKU模版',
|
||
'小红书种草',
|
||
// 其他
|
||
'音乐',
|
||
'品牌VI灵感',
|
||
// 任务相关
|
||
'任务-详情',
|
||
'任务-3D原图',
|
||
'任务-AI原图',
|
||
'任务-实拍原图',
|
||
'任务-视频截图',
|
||
]
|
||
const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string }[]>([])
|
||
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<Array<{ value: string; label: string; count: number }>>([])
|
||
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函数引用
|
||
|
||
// ✅ 清理转码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 [totalMaterialsCount, setTotalMaterialsCount] = useState(0)
|
||
// 分页+懒加载相关状态
|
||
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 [hasCheckedIntranet, setHasCheckedIntranet] = useState(false) // 是否已检查过内网状态
|
||
|
||
// 检测内网用户访问公网域名,自动跳转到内网地址
|
||
// 同时检测:如果在内网地址但无法访问,自动回退到公网域名
|
||
useEffect(() => {
|
||
if (hasCheckedIntranet || typeof window === 'undefined') return
|
||
|
||
const checkIntranet = async () => {
|
||
try {
|
||
// 判断当前是否在内网地址上
|
||
const isOnIntranetAddress = window.location.hostname === '192.168.58.20' ||
|
||
window.location.hostname.match(/^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./)
|
||
|
||
// 如果当前在内网地址上,检查是否能正常访问
|
||
if (isOnIntranetAddress) {
|
||
try {
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), 3000) // 3秒超时
|
||
|
||
// 尝试访问当前服务器的 API
|
||
const testResponse = await fetch('/api/network-status', {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
signal: controller.signal
|
||
})
|
||
|
||
clearTimeout(timeoutId)
|
||
|
||
if (!testResponse.ok) {
|
||
throw new Error('API 返回非 200 状态')
|
||
}
|
||
|
||
// 能正常访问,继续正常流程
|
||
console.debug('[网络检测] 当前在内网地址,且能正常访问')
|
||
} catch (error) {
|
||
// 无法访问内网地址,说明用户可能在公网环境但使用了内网地址
|
||
console.warn('[网络检测] 当前在内网地址但无法访问,可能用户已切换到公网环境,自动回退到公网域名')
|
||
|
||
// 重定向到公网域名
|
||
const publicDomain = '3d.yydd.ai' // 公网域名
|
||
const currentPath = window.location.pathname + window.location.search
|
||
window.location.replace(`https://${publicDomain}${currentPath}`)
|
||
return
|
||
}
|
||
|
||
// 如果在内网地址且能正常访问,直接返回,不需要进一步检测
|
||
setHasCheckedIntranet(true)
|
||
return
|
||
}
|
||
|
||
// 判断是否为公网域名
|
||
const isPublicDomain = window.location.hostname !== '192.168.58.20' &&
|
||
window.location.hostname !== 'localhost' &&
|
||
!window.location.hostname.match(/^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./)
|
||
|
||
// 如果访问的是公网域名,尝试检测用户是否在内网
|
||
if (isPublicDomain) {
|
||
// 检查是否已经跳过(避免无限重定向)
|
||
// 注意:每次访问公网域名时都重新检测,不依赖 sessionStorage
|
||
// 这样可以确保内网用户在清除缓存后仍能正常跳转
|
||
const hasRedirected = sessionStorage.getItem('intranet-auto-redirect')
|
||
if (hasRedirected && window.location.hostname === '3d.yydd.ai') {
|
||
// 如果已经跳过,且当前访问的是公网域名,清除标记并重新检测
|
||
console.debug('[内网切换] 检测到已跳过标记,但访问的是公网域名,重新检测...')
|
||
sessionStorage.removeItem('intranet-auto-redirect')
|
||
} else if (hasRedirected) {
|
||
// 如果已经跳过,且当前访问的不是公网域名,跳过检测
|
||
setHasCheckedIntranet(true)
|
||
return
|
||
}
|
||
|
||
// 检查当前协议
|
||
const isHttps = window.location.protocol === 'https:'
|
||
|
||
// 统一检测逻辑:无论HTTPS还是HTTP,都先尝试直接访问内网地址
|
||
// 如果能访问内网地址,说明用户在内网,直接跳转
|
||
let canAccessIntranet = false
|
||
|
||
try {
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), 2000) // 2秒超时,快速失败
|
||
|
||
// 尝试直接访问内网地址(通过fetch)
|
||
const intranetUrl = `http://192.168.58.20:4320/api/network-status`
|
||
const intranetResponse = await fetch(intranetUrl, {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
mode: 'cors', // 使用CORS模式
|
||
credentials: 'omit', // 不发送cookies,避免CORS问题
|
||
signal: controller.signal
|
||
})
|
||
|
||
clearTimeout(timeoutId)
|
||
|
||
if (intranetResponse.ok) {
|
||
const intranetData = await intranetResponse.json()
|
||
const hostname = intranetData.hostname || ''
|
||
|
||
// 关键判断:如果返回的hostname是192.168.58.20,说明客户端能直接访问内网地址
|
||
const isTargetServer = hostname === '192.168.58.20' || intranetData.isIntranet === true
|
||
|
||
if (isTargetServer) {
|
||
canAccessIntranet = true
|
||
console.debug('[内网切换] 直接访问内网地址成功(hostname:', hostname, ', isIntranet:', intranetData.isIntranet, '),判定为内网用户')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
// 访问失败(可能是混合内容阻止或网络问题),尝试通过后端API检测
|
||
console.debug('[内网切换] 直接访问内网地址失败(可能是混合内容或网络问题),尝试通过后端API检测:', error instanceof Error ? error.message : error)
|
||
|
||
// 如果直接访问失败,通过后端API检测(备用方案)
|
||
try {
|
||
const controller = new AbortController()
|
||
const timeoutId = setTimeout(() => controller.abort(), 2000)
|
||
|
||
const networkStatusUrl = `/api/network-status`
|
||
const networkResponse = await fetch(networkStatusUrl, {
|
||
method: 'GET',
|
||
cache: 'no-store',
|
||
signal: controller.signal
|
||
})
|
||
|
||
clearTimeout(timeoutId)
|
||
|
||
if (networkResponse.ok) {
|
||
const networkData = await networkResponse.json()
|
||
const clientIp = networkData.clientIp || 'unknown'
|
||
const hostname = networkData.hostname || ''
|
||
const isIntranet = networkData.isIntranet || false
|
||
|
||
console.debug('[内网切换] 后端API检测结果:', {
|
||
clientIp,
|
||
hostname,
|
||
isIntranet,
|
||
networkData
|
||
})
|
||
|
||
// 注意:如果clientIp是公网IP(如58.216.201.86),可能是代理IP,不准确
|
||
// 但如果后端返回 isIntranet: true,说明后端通过其他方式(如hostname)判断为内网
|
||
if (isIntranet) {
|
||
canAccessIntranet = true
|
||
console.debug('[内网切换] 后端API检测:判定为内网用户(isIntranet:', isIntranet, ')')
|
||
} else {
|
||
// 即使clientIp是公网IP,也可能是通过代理访问的内网用户
|
||
// 在这种情况下,如果直接访问内网地址失败(可能是混合内容阻止),
|
||
// 我们可以尝试通过创建一个隐藏的iframe来检测是否能够访问内网地址
|
||
console.debug('[内网切换] 后端API检测:clientIp为公网IP(', clientIp, '),可能是代理IP,尝试其他检测方式')
|
||
|
||
// 尝试通过创建隐藏的iframe来检测是否能够访问内网地址
|
||
// 这可以绕过混合内容的限制
|
||
const testIframe = document.createElement('iframe')
|
||
testIframe.style.display = 'none'
|
||
testIframe.src = `http://192.168.58.20:4320/api/network-status`
|
||
|
||
const iframeTimeout = setTimeout(() => {
|
||
try {
|
||
document.body.removeChild(testIframe)
|
||
} catch {}
|
||
}, 3000)
|
||
|
||
testIframe.onload = () => {
|
||
clearTimeout(iframeTimeout)
|
||
try {
|
||
document.body.removeChild(testIframe)
|
||
canAccessIntranet = true
|
||
console.debug('[内网切换] iframe检测:能访问内网地址,判定为内网用户')
|
||
} catch {}
|
||
}
|
||
|
||
testIframe.onerror = () => {
|
||
clearTimeout(iframeTimeout)
|
||
try {
|
||
document.body.removeChild(testIframe)
|
||
} catch {}
|
||
console.debug('[内网切换] iframe检测:无法访问内网地址')
|
||
}
|
||
|
||
document.body.appendChild(testIframe)
|
||
|
||
// 等待iframe加载(最多等待2秒)
|
||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||
}
|
||
}
|
||
} catch (apiError) {
|
||
console.debug('[内网切换] 后端API检测失败:', apiError)
|
||
}
|
||
}
|
||
|
||
// 如果检测到在内网,执行跳转
|
||
if (canAccessIntranet) {
|
||
console.debug('[内网切换] 检测到内网访问,自动跳转到内网地址')
|
||
sessionStorage.setItem('intranet-auto-redirect', 'true')
|
||
const currentPath = window.location.pathname + window.location.search
|
||
// 跳转到HTTP内网地址(内网环境可以使用HTTP)
|
||
window.location.replace(`http://192.168.58.20:4320${currentPath}`)
|
||
return
|
||
} else {
|
||
console.debug('[内网切换] 未检测到内网访问,保持当前页面')
|
||
}
|
||
} else {
|
||
// 如果访问的是内网地址,不需要切换
|
||
console.debug('[内网切换] 访问的是内网地址,不需要切换')
|
||
}
|
||
} catch (error) {
|
||
console.warn('网络检测失败:', error)
|
||
} finally {
|
||
setHasCheckedIntranet(true)
|
||
}
|
||
}
|
||
|
||
// 延迟检测,避免影响页面加载
|
||
const timer = setTimeout(checkIntranet, 1000)
|
||
return () => clearTimeout(timer)
|
||
}, [hasCheckedIntranet])
|
||
|
||
// 实时搜索处理函数 - 输入立即更新,搜索由 useDeferredValue 控制
|
||
const handleSearchChange = useCallback((value: string) => {
|
||
setSearchKeyword(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 sort = params.get('sortBy')
|
||
const sortOrderParam = params.get('sortOrder')
|
||
|
||
if (materialType) setSelectedMaterialType(materialType)
|
||
if (category) setSelectedCategory(category)
|
||
if (scene) setSelectedScene(scene)
|
||
if (platform) setSelectedPlatform(platform)
|
||
if (productCategory) setSelectedProductCategory(productCategory)
|
||
if (month) setSelectedMonth(month)
|
||
if (sort) setSortBy(sort)
|
||
if (sortOrderParam === 'asc' || sortOrderParam === 'desc') setSortOrder(sortOrderParam)
|
||
}, [])
|
||
|
||
// 同步筛选状态到URL参数
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
|
||
const params = new URLSearchParams()
|
||
if (selectedMaterialType) params.set('materialType', selectedMaterialType) // 只有选择了素材类型才添加参数
|
||
if (selectedCategory) params.set('category', selectedCategory)
|
||
if (selectedScene) params.set('scene', selectedScene)
|
||
if (selectedPlatform) params.set('platform', selectedPlatform)
|
||
if (selectedProductCategory) params.set('productCategory', selectedProductCategory)
|
||
if (selectedMonth) params.set('month', selectedMonth)
|
||
if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy)
|
||
if (sortOrder && sortOrder !== 'desc') params.set('sortOrder', sortOrder)
|
||
|
||
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname
|
||
window.history.replaceState({}, '', newUrl)
|
||
}, [selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
|
||
|
||
useEffect(() => {
|
||
// 先加载基础数据(分类、场景等)
|
||
fetchCategories()
|
||
fetchScenes()
|
||
fetchPlatforms()
|
||
fetchProductCategories()
|
||
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 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.length > 0 ? source : DEFAULT_MATERIAL_TYPES).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 {
|
||
// 失败则回退默认列表
|
||
const mapped = DEFAULT_MATERIAL_TYPES.map((t) => ({ value: t, label: t }))
|
||
setMaterialTypes(mapped)
|
||
if (!mapped.some((t) => t.value === selectedMaterialType)) {
|
||
setSelectedMaterialType('')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取可见素材类型失败:', error)
|
||
const mapped = DEFAULT_MATERIAL_TYPES.map((t) => ({ value: t, label: t }))
|
||
setMaterialTypes(mapped)
|
||
if (!mapped.some((t) => t.value === selectedMaterialType)) {
|
||
setSelectedMaterialType('')
|
||
}
|
||
}
|
||
}
|
||
loadViewableTypes()
|
||
}, [status])
|
||
|
||
// 恢复滚动位置和页面状态(从详情页返回时)
|
||
// 使用 pathname 作为依赖,当路由变为首页时触发恢复
|
||
const hasRestoredRef = useRef(false)
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
// 只在首页路径时执行
|
||
if (pathname !== '/') 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分钟
|
||
|
||
// 如果缓存未过期,恢复状态
|
||
if (cacheAge < CACHE_EXPIRY) {
|
||
// 恢复筛选条件
|
||
if (cache.selectedCategory) setSelectedCategory(cache.selectedCategory)
|
||
if (cache.selectedScene) setSelectedScene(cache.selectedScene)
|
||
if (cache.selectedMaterialType) setSelectedMaterialType(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 (cache.materials && Array.isArray(cache.materials)) {
|
||
setMaterials(cache.materials)
|
||
setDisplayedCount(cache.displayedCount || cache.materials.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 === '/') {
|
||
// 延迟重置,确保恢复逻辑已执行
|
||
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, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
|
||
|
||
// 实时搜索:当 deferredSearchKeyword 变化时触发搜索
|
||
const prevSearchKeywordRef = useRef<string | null>(null)
|
||
useEffect(() => {
|
||
if (status !== 'authenticated') return
|
||
|
||
// 首次渲染时,记录初始值但不触发搜索(由筛选 useEffect 处理)
|
||
if (prevSearchKeywordRef.current === null) {
|
||
prevSearchKeywordRef.current = deferredSearchKeyword
|
||
return
|
||
}
|
||
|
||
// 只有搜索词真的变化了才触发搜索
|
||
if (deferredSearchKeyword === prevSearchKeywordRef.current) {
|
||
return
|
||
}
|
||
prevSearchKeywordRef.current = deferredSearchKeyword
|
||
|
||
// 重置分页状态
|
||
setCurrentPage(1)
|
||
setDisplayedCount(24)
|
||
setHasMore(true)
|
||
|
||
// 直接调用搜索请求(不通过 fetchMaterialsRef,避免闭包问题)
|
||
// 这里不能直接调用 fetchMaterials,因为会有循环依赖
|
||
// 解决方案:直接在这里构建并发送请求
|
||
const performSearch = async () => {
|
||
try {
|
||
// 构建查询参数
|
||
const params = new URLSearchParams()
|
||
params.append('page', '1')
|
||
params.append('pageSize', String(pageSize))
|
||
// 使用当前 useEffect 中的 deferredSearchKeyword
|
||
if (deferredSearchKeyword) params.append('search', deferredSearchKeyword)
|
||
if (selectedCategory) params.append('category', selectedCategory)
|
||
if (selectedScene) params.append('scene', selectedScene)
|
||
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
|
||
if (selectedPlatform) params.append('platform', selectedPlatform)
|
||
if (selectedProductCategory) params.append('productCategory', selectedProductCategory)
|
||
if (selectedMonth) params.append('month', selectedMonth)
|
||
params.append('sortBy', sortBy)
|
||
params.append('sortOrder', sortOrder)
|
||
|
||
const url = `/api/materials?${params.toString()}`
|
||
|
||
const response = await fetch(url)
|
||
if (!response.ok) throw new Error('搜索请求失败')
|
||
|
||
const data = await response.json()
|
||
setMaterials(data.materials || [])
|
||
setHasMore((data.materials || []).length >= pageSize)
|
||
setCurrentPage(1)
|
||
setDisplayedCount(24)
|
||
} catch (error) {
|
||
console.error('搜索请求失败:', error)
|
||
}
|
||
}
|
||
|
||
performSearch()
|
||
}, [status, deferredSearchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder, pageSize])
|
||
|
||
// 获取主题颜色
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 移除筛选条件保存逻辑(避免滚动问题)
|
||
|
||
// 当选择店铺变化时,重新获取场景数量(父子关系联动)
|
||
useEffect(() => {
|
||
fetchScenes(selectedCategory || undefined)
|
||
}, [selectedCategory])
|
||
|
||
// 获取素材列表(支持分页和追加模式)
|
||
const fetchMaterials = useCallback(
|
||
async (page = 1, append = false) => {
|
||
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 = new URLSearchParams()
|
||
if (deferredSearchKeyword) params.append('search', deferredSearchKeyword)
|
||
if (selectedCategory) params.append('category', selectedCategory)
|
||
if (selectedScene) params.append('scene', selectedScene)
|
||
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
|
||
if (selectedPlatform) params.append('platform', selectedPlatform)
|
||
if (selectedProductCategory) params.append('productCategory', selectedProductCategory)
|
||
if (selectedMonth) params.append('month', selectedMonth)
|
||
if (sortBy) params.append('sortBy', sortBy)
|
||
if (sortOrder) params.append('sortOrder', sortOrder)
|
||
params.append('page', page.toString())
|
||
params.append('pageSize', pageSize.toString())
|
||
|
||
const url = `/api/materials${params.toString() ? '?' + params.toString() : ''}`
|
||
const response = await fetch(url)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// 素材ID验证和previewUrl fallback
|
||
const materialsList = ((data.materials || data) as Material[])
|
||
.map((e) => {
|
||
// 验证ID
|
||
if (!e.id || typeof e.id !== 'string') return null
|
||
const trimmedId = e.id.trim()
|
||
if (!trimmedId || trimmedId.length < 10 || trimmedId.length > 100) return null
|
||
|
||
// previewUrl fallback逻辑
|
||
const previewUrl = e.previewUrl ||
|
||
e.videoCoverUrl ||
|
||
(e.displayImages && e.displayImages.length > 0 ? e.displayImages[0] : '')
|
||
|
||
return { ...e, id: trimmedId, previewUrl }
|
||
})
|
||
.filter((e): e is Material => e !== null)
|
||
|
||
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)
|
||
|
||
// 获取月份统计和总数
|
||
fetchMonthsAndTotal()
|
||
|
||
// 对需要转码的视频素材启动轮询
|
||
appendedItems.forEach((material) => {
|
||
if (isVideoMaterialType(material.materialType) && material.videoSourceUrl) {
|
||
startTranscodePoll(material, 0)
|
||
}
|
||
})
|
||
|
||
return appendedCount
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching materials:', error)
|
||
if (!append) {
|
||
setMaterials([])
|
||
setDisplayedCount(0)
|
||
}
|
||
} finally {
|
||
if (!append) {
|
||
setLoading(false)
|
||
setIsTransitionLoading(false)
|
||
}
|
||
}
|
||
return 0
|
||
},
|
||
[materials.length, deferredSearchKeyword, selectedCategory, selectedScene, selectedMaterialType, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder, pageSize]
|
||
)
|
||
|
||
// 更新fetchMaterialsRef
|
||
useEffect(() => {
|
||
fetchMaterialsRef.current = fetchMaterials
|
||
}, [fetchMaterials])
|
||
|
||
// 判断是否为视频类型素材
|
||
const isVideoMaterialType = (materialType: string): boolean => {
|
||
return materialType === '主图视频' || materialType === '安装视频' || 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 (!isVideoMaterialType(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,
|
||
videoPreview540Url: m.videoPreview540Url || data.hasPreview540 ? data.hasPreview540 : null,
|
||
videoPreviewClipUrl: m.videoPreviewClipUrl || data.hasClip ? data.hasClip : null,
|
||
videoCoverUrl: m.videoCoverUrl || data.hasCover ? data.hasCover : null,
|
||
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) 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, displayedCount, materials.length, hasMore, currentPage, fetchMaterials])
|
||
|
||
// IntersectionObserver 逻辑已移至 LoadMoreTrigger 组件
|
||
|
||
const fetchCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching categories:', error)
|
||
}
|
||
}
|
||
|
||
const fetchScenes = async (categoryName?: string) => {
|
||
try {
|
||
const url = categoryName
|
||
? `/api/scenes?category=${encodeURIComponent(categoryName)}`
|
||
: '/api/scenes'
|
||
const response = await fetch(url)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setScenes(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching scenes:', error)
|
||
}
|
||
}
|
||
|
||
const fetchPlatforms = async () => {
|
||
try {
|
||
const response = await fetch('/api/platforms')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setPlatforms(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching platforms:', error)
|
||
}
|
||
}
|
||
|
||
// 获取品类列表
|
||
const fetchProductCategories = async () => {
|
||
try {
|
||
const response = await fetch('/api/product-categories')
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
setProductCategories(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching product categories:', error)
|
||
}
|
||
}
|
||
|
||
// 根据素材类型获取角标背景颜色
|
||
const getMaterialTypeBadgeColor = (materialType: string): string => {
|
||
// 判断是否为视频类型
|
||
const isVideoType = materialType === '主图视频' || materialType === '安装视频' || 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 fetchMonthsAndTotal = async () => {
|
||
try {
|
||
// 构建查询参数(排除productCategory,因为"全部"按钮需要显示当前其他筛选条件下的总数)
|
||
const params = new URLSearchParams()
|
||
if (searchKeyword) params.append('search', searchKeyword)
|
||
if (selectedCategory) params.append('category', selectedCategory)
|
||
if (selectedScene) params.append('scene', selectedScene)
|
||
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
|
||
if (selectedPlatform) params.append('platform', selectedPlatform)
|
||
// 注意:不包含productCategory参数,因为"全部"按钮需要显示当前其他筛选条件下的总数
|
||
if (selectedMonth) params.append('month', selectedMonth)
|
||
params.append('pageSize', '1') // 只需要总数,不需要数据
|
||
|
||
const response = await fetch(`/api/materials?${params.toString()}`)
|
||
if (response.ok) {
|
||
const data = await response.json()
|
||
// 使用返回的total作为"全部"按钮的计数
|
||
setTotalMaterialsCount(data.total || 0)
|
||
}
|
||
|
||
// 获取月份统计(使用所有素材)
|
||
const statsResponse = await fetch('/api/materials/stats')
|
||
if (statsResponse.ok) {
|
||
const statsData = await statsResponse.json()
|
||
setMonths(statsData.months || [])
|
||
}
|
||
} catch (error) {
|
||
console.error('Error fetching months and total:', error)
|
||
}
|
||
}
|
||
|
||
// 格式化文件大小
|
||
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 = `${window.location.origin}/share/${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="min-h-screen flex items-center justify-center dark:bg-gray-900">
|
||
<div className="text-xl text-gray-600 dark:text-gray-400">加载中...</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!session) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||
<Header session={session} />
|
||
{/* 维护通知横幅已在 Header 中显示 */}
|
||
|
||
{/* 收藏操作提示气泡 */}
|
||
{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://3d.yydd.ai,通过DNS解析自动区分内网/外网 */}
|
||
|
||
<RankingPanel />
|
||
|
||
<main className="container mx-auto px-4 py-8 max-w-7xl">
|
||
{/* Header Section */}
|
||
<div className="mb-8">
|
||
<div className="flex justify-end items-center mb-6">
|
||
<button
|
||
onClick={() => setShowUploadModal(true)}
|
||
className="px-6 py-3 rounded-apple text-white font-medium transition-all hover:opacity-90 shadow-apple"
|
||
style={{ backgroundColor: themeColor }}
|
||
>
|
||
<span className="mr-2">+</span>
|
||
上传素材
|
||
</button>
|
||
</div>
|
||
|
||
{/* Search Bar - 实时搜索 */}
|
||
<SearchBar
|
||
value={searchKeyword}
|
||
onChange={handleSearchChange}
|
||
placeholder="搜索SPU、店铺、场景类别、作者..."
|
||
isSearching={isSearchPending}
|
||
/>
|
||
|
||
{/* 筛选面板组件 */}
|
||
<FilterPanel
|
||
selectedMaterialType={selectedMaterialType}
|
||
setSelectedMaterialType={setSelectedMaterialType}
|
||
selectedCategory={selectedCategory}
|
||
setSelectedCategory={setSelectedCategory}
|
||
selectedScene={selectedScene}
|
||
setSelectedScene={setSelectedScene}
|
||
selectedPlatform={selectedPlatform}
|
||
setSelectedPlatform={setSelectedPlatform}
|
||
selectedProductCategory={selectedProductCategory}
|
||
setSelectedProductCategory={setSelectedProductCategory}
|
||
selectedMonth={selectedMonth}
|
||
setSelectedMonth={setSelectedMonth}
|
||
sortBy={sortBy}
|
||
setSortBy={setSortBy}
|
||
sortOrder={sortOrder}
|
||
setSortOrder={setSortOrder}
|
||
materialTypes={materialTypes}
|
||
categories={categories}
|
||
scenes={scenes}
|
||
platforms={platforms}
|
||
productCategories={productCategories}
|
||
months={months}
|
||
totalMaterialsCount={totalMaterialsCount}
|
||
materialsCount={materials.length}
|
||
isFilterCollapsed={isFilterCollapsed}
|
||
setIsFilterCollapsed={setIsFilterCollapsed}
|
||
themeColor={themeColor}
|
||
/>
|
||
|
||
{/* Active Filters Display - Material_Type不显示在这里 */}
|
||
{(searchKeyword || 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>
|
||
)}
|
||
{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={() => setSelectedCategory('')}
|
||
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('')
|
||
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 || selectedCategory || selectedScene || selectedMaterialType || selectedPlatform)}
|
||
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)
|
||
fetchCategories()
|
||
fetchScenes()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* 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>
|
||
)
|
||
}
|