gongxiangp2/app/page.tsx
chenpei 752b7bf999 fix: 修复批量上传和视频扩展名问题
主要修复:

- 修复批量上传闭包陷阱,防止文件丢失

- 添加 fileName 参数传递原始文件名,保留正确扩展名

- 添加取消检查回调,改进批量上传取消功能

其他改进:

- 优化搜索功能和管理后台

- 添加 FRP 故障诊断文档

- 更新环境验证和网络检测
2026-02-03 11:12:50 +08:00

1933 lines
76 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import { useEffect, useState, useRef, useCallback, useDeferredValue } 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 { 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)
// 修复:使用 Map 存储每个素材的下载进度,避免进度条跳动问题
const [downloadingId, setDownloadingId] = useState<string | null>(null)
const downloadProgressMap = useRef<Map<string, number>>(new Map())
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 handleDownload = (id: string) => {
// 如果当前素材正在下载,忽略重复点击
if (downloadingId === id) {
console.log(`[Download] 素材 ${id} 正在下载中,忽略重复点击`)
return
}
setDownloadingId(id)
// 初始化该素材的下载进度为 0
downloadProgressMap.current.set(id, 0)
setDownloadProgress(0)
const xhr = new XMLHttpRequest()
// 监听下载进度
// 注意:直接使用 id 参数,不要使用 downloadingId 状态(闭包陷阱)
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
// 更新进度(id 是闭包参数,值是正确的)
downloadProgressMap.current.set(id, percentComplete)
setDownloadProgress(percentComplete)
}
})
xhr.addEventListener('load', () => {
// 清理进度记录
downloadProgressMap.current.delete(id)
if (xhr.status === 200) {
try {
// 从响应头获取 Content-Type(视频、音频等不同类型)
const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream'
const blob = new Blob([xhr.response], { type: contentType })
// 从响应头获取文件名
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
let filename = 'material' // 默认文件名,扩展名从服务器获取
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/)
if (filenameMatch && filenameMatch[1]) {
filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, ''))
}
}
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
a.remove()
window.URL.revokeObjectURL(url)
// 更新本地状态的下载次数
setMaterials(prev => prev.map(m =>
m.id === id ? { ...m, downloadCount: (m.downloadCount || 0) + 1 } : m
))
} catch (error) {
console.error('下载处理失败:', error)
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
}
} else {
// 当 responseType 为 arraybuffer 时,需要将响应转换为文本来解析错误
try {
const decoder = new TextDecoder('utf-8')
const errorText = decoder.decode(xhr.response)
const error = errorText ? JSON.parse(errorText) : { error: '下载失败' }
const { message } = parseDownloadError(error)
showToast(message, { variant: 'error' })
} catch {
const { message } = parseDownloadError({ code: 'UNKNOWN_ERROR' })
showToast(message, { variant: 'error' })
}
}
// 清理下载状态
setDownloadingId(null)
setDownloadProgress(0)
})
xhr.addEventListener('error', () => {
// 清理进度记录
downloadProgressMap.current.delete(id)
const { message } = parseDownloadError({ code: 'NETWORK_ERROR' })
showToast(message, { variant: 'error' })
// 清理下载状态
setDownloadingId(null)
setDownloadProgress(0)
})
xhr.open('GET', `/api/materials/download?id=${id}`)
xhr.responseType = 'arraybuffer'
xhr.send()
}
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={handleDownload}
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>
)
}