gongxiangp2/app/page.tsx
chenpei 0ce59be34e 备份:组织架构设计方案讨论前的代码快照
- 包含所有当前修改和新功能
- 准备开始组织架构优化实施方案
2025-12-30 13:20:26 +08:00

1523 lines
58 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 } from 'react'
import { useSession } from 'next-auth/react'
import { useRouter } 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 { 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 [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('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场景',
'详情页',
'材质',
'模特',
'实拍图片',
'买家秀图片',
'小红书种草',
'主图模版',
'SKU模版',
'主图视频',
'安装视频',
'种草视频',
'音乐',
'C4D模型',
'标准饰品',
'标准模型',
'品牌VI灵感',
'矢量文件',
]
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函数引用
// 音频播放状态
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提示
const searchDebounceRef = useRef<NodeJS.Timeout | null>(null) // 搜索防抖定时器
const [showIntranetTip, setShowIntranetTip] = useState(false) // 内网切换提示
// 搜索防抖处理函数
const handleSearchChange = useCallback((value: string) => {
// 立即更新显示值(不触发搜索,只更新输入框显示)
setSearchKeyword(value)
// 清除之前的定时器
if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current)
}
// 设置新的定时器,500ms后触发搜索
searchDebounceRef.current = setTimeout(() => {
console.log('[搜索防抖] 触发搜索:', value)
setCurrentPage(1) // 重置到第一页
setDisplayedCount(24) // 重置显示数量
setHasMore(true) // 重置hasMore状态
// 使用ref访问最新的fetchMaterials,避免闭包问题
if (fetchMaterialsRef.current) {
fetchMaterialsRef.current(1, false)
}
}, 500)
}, [])
// 移除页面卸载时保存状态的逻辑(避免滚动问题)
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
}
}, [status, router])
// 检查是否需要显示内网切换提示(使用后端判断)
useEffect(() => {
const checkNetworkStatus = async () => {
if (status !== 'authenticated') return
const tipDismissed = sessionStorage.getItem('intranet_tip_dismissed')
if (tipDismissed === 'true') return
try {
// 调用后端API获取准确的网络状态
const response = await fetch('/api/network-status')
const data = await response.json()
if (data.success) {
console.log(`[Network Status] isIntranet: ${data.isIntranet}, isPublicDomain: ${data.isPublicDomain}`)
// 如果用户在内网但访问的是公网域名,显示切换提示
if (data.isIntranet && data.isPublicDomain) {
console.log('💡 后端检测到用户在内网访问公网,显示切换提示')
setShowIntranetTip(true)
}
}
} catch (error) {
console.error('网络状态检测失败:', error)
}
}
checkNetworkStatus()
}, [status])
const handleDismissIntranetTip = () => {
sessionStorage.setItem('intranet_tip_dismissed', 'true')
setShowIntranetTip(false)
}
const handleSwitchToIntranet = () => {
window.location.href = 'http://192.168.58.20:4320'
}
// 从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 && selectedMaterialType !== '3D场景') 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) => (t === '3DMAX' ? '3D场景' : 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) {
setSelectedMaterialType(hasPreferred ? preferredDefault : '')
}
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])
// 恢复滚动位置和页面状态(从详情页返回时)
useEffect(() => {
if (typeof window === 'undefined') 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)
}
// 恢复滚动位置(延迟执行,确保内容已渲染)
if (savedScrollPosition) {
setTimeout(() => {
window.scrollTo({
top: parseInt(savedScrollPosition, 10),
behavior: 'auto'
})
// 清除已恢复的缓存
sessionStorage.removeItem('home_scroll_position')
sessionStorage.removeItem('home_materials_cache')
}, 100)
}
return // 使用缓存,不重新获取数据
} else {
// 缓存过期,清除
sessionStorage.removeItem('home_scroll_position')
sessionStorage.removeItem('home_materials_cache')
}
} else if (savedScrollPosition) {
// 只有滚动位置,没有缓存数据,恢复滚动位置
setTimeout(() => {
window.scrollTo({
top: parseInt(savedScrollPosition, 10),
behavior: 'auto'
})
sessionStorage.removeItem('home_scroll_position')
}, 100)
}
} catch (e) {
console.warn('[Home] Failed to restore state from sessionStorage:', e)
sessionStorage.removeItem('home_scroll_position')
sessionStorage.removeItem('home_materials_cache')
}
}, []) // 只在组件挂载时执行一次
// 当筛选条件变化时,重新获取素材列表(延迟执行,避免初始化时重复调用)
useEffect(() => {
// 只有在已登录状态下才获取素材
if (status !== 'authenticated') return
// 如果已从缓存恢复,不重新获取
const hasRestoredCache = sessionStorage.getItem('home_materials_cache')
if (hasRestoredCache) {
// 清除缓存标记,下次筛选变化时正常获取
sessionStorage.removeItem('home_materials_cache')
return
}
// 延迟执行,确保状态已恢复
const timer = setTimeout(() => {
fetchMaterials(1, false)
}, 100)
return () => clearTimeout(timer)
}, [status, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
// 获取主题颜色
const fetchThemeColor = async () => {
try {
const response = await fetch('/api/system-settings')
if (response.ok) {
const data = await response.json()
setThemeColor(data.headerBgColor || '#1f2937')
}
} catch (error) {
console.error('获取主题颜色失败:', error)
}
}
// 移除筛选条件保存逻辑(避免滚动问题)
// 当选择店铺变化时,重新获取场景数量(父子关系联动)
useEffect(() => {
fetchScenes(selectedCategory || undefined)
}, [selectedCategory])
// 获取素材列表(支持分页和追加模式)
const fetchMaterials = useCallback(
async (page = 1, append = false) => {
try {
const showFullLoading = !append && page === 1 && materials.length === 0
if (showFullLoading) {
setLoading(true)
} else if (!append) {
setIsTransitionLoading(true)
}
if (!append) setHasMore(true)
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)
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, searchKeyword, 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) {
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
setAudioElement(null) // 清理 audio 元素引用,确保 AudioVisualizer 正确清理
setPlayingAudioId(null)
setAudioProgress(0)
setAudioDuration(0)
setAudioCurrentTime(0)
return
}
// 注意:不再复用旧的 audio 元素,每次都创建新的,确保 AudioVisualizer 能正确初始化
// 这样可以避免 "already connected" 错误
// 停止之前的音频并清理
if (audioRef.current) {
audioRef.current.pause()
audioRef.current = null
}
// 清理旧的 audio 元素引用,确保 AudioVisualizer 能正确切换到新的音频
setAudioElement(null)
// 创建新的音频元素
const audio = new Audio(audioUrl)
audio.crossOrigin = 'anonymous' // 允许跨域访问(用于AudioVisualizer)
audio.preload = 'auto' // 预加载音频
audioRef.current = audio
setAudioElement(audio) // 设置audio元素供AudioVisualizer使用
// 监听加载错误
audio.addEventListener('error', (e) => {
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)
audioRef.current = null
setAudioElement(null)
})
// 监听元数据加载完成(获取时长)
audio.addEventListener('loadedmetadata', () => {
console.log('[Home] Audio metadata loaded, duration:', audio.duration)
setAudioDuration(audio.duration || 0)
})
// 监听可以播放(使用canplay事件,有足够数据就开始播放,减少延迟)
const handleCanPlay = () => {
console.log('[Home] Audio can play:', audioUrl)
audio.play().then(() => {
console.log('[Home] Audio playback started:', audioUrl)
setPlayingAudioId(materialId)
}).catch((error) => {
console.error('[Home] Audio play failed:', error, 'URL:', audioUrl)
showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' })
setPlayingAudioId(null)
audioRef.current = null
setAudioElement(null)
})
audio.removeEventListener('canplay', handleCanPlay)
}
audio.addEventListener('canplay', handleCanPlay)
// 如果音频已经可以播放,立即播放
if (audio.readyState >= 2) {
handleCanPlay()
}
// 监听播放进度
audio.addEventListener('timeupdate', () => {
if (audio.duration) {
setAudioProgress((audio.currentTime / audio.duration) * 100)
setAudioCurrentTime(audio.currentTime)
}
})
// 监听播放结束
audio.addEventListener('ended', () => {
setPlayingAudioId(null)
setAudioProgress(0)
setAudioDuration(0)
setAudioCurrentTime(0)
})
// 开始加载音频
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 === '矢量文件') {
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) => {
setDownloadingId(id)
setDownloadProgress(0)
const xhr = new XMLHttpRequest()
// 监听下载进度
xhr.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100)
setDownloadProgress(percentComplete)
}
})
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
try {
const blob = new Blob([xhr.response], { type: 'application/zip' })
// 从响应头获取文件名
const contentDisposition = xhr.getResponseHeader('Content-Disposition')
let filename = 'material.zip'
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)
showToast('下载失败,请稍后重试', { variant: 'error' })
}
} else {
try {
const errorText = xhr.responseText
const error = JSON.parse(errorText)
showToast(error.error || '下载失败', { variant: 'error' })
} catch {
showToast('下载失败', { variant: 'error' })
}
}
setDownloadingId(null)
setDownloadProgress(0)
})
xhr.addEventListener('error', () => {
showToast('下载失败,请稍后重试', { 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 response.json()
showToast(error.error || '操作失败', { variant: 'error' })
}
} catch (error) {
console.error('Like error:', error)
showToast('操作失败,请稍后重试', { variant: 'error' })
}
}
// 收藏/取消收藏
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 response.json()
setFavoriteNotification(error.error || '操作失败')
setTimeout(() => setFavoriteNotification(null), 2000)
return
}
const result = await response.json()
// 更新本地状态 - 使用 result.isFavorited(与反编译文件一致)
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)
setFavoriteNotification('操作失败,请稍后重试')
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)
}
// 跳转到详情页
router.push(`/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 {
showToast('重试转码失败', { variant: 'error' })
}
} catch (error) {
console.error('Retry transcode error:', error)
showToast('重试转码失败', { variant: 'error' })
}
}
// 删除素材
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 data = await response.json()
showToast(data.error || '删除失败', { variant: 'error' })
}
} catch (error) {
console.error('Error deleting material:', error)
showToast('删除失败,请稍后重试', { variant: 'error' })
}
}
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>
)}
{/* 内网切换提示 */}
{showIntranetTip && (
<div className="bg-gradient-to-r from-blue-500 to-blue-600 text-white shadow-lg">
<div className="container mx-auto px-4 py-3 max-w-7xl">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1">
<svg className="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="flex-1">
<p className="font-medium text-sm sm:text-base">
🚀 检测到您正在内网环境访问公网!
</p>
<p className="text-xs sm:text-sm opacity-90 mt-0.5">
切换到内网地址 (192.168.58.20) 可获得 <strong>10倍以上</strong> 的速度提升,上传下载更快
</p>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<button
onClick={handleSwitchToIntranet}
className="px-4 py-2 bg-white text-blue-600 rounded-lg hover:bg-blue-50 transition-colors font-medium text-sm whitespace-nowrap"
>
立即切换
</button>
<button
onClick={handleDismissIntranetTip}
className="px-3 py-2 hover:bg-blue-700 rounded-lg transition-colors"
title="关闭提示"
>
<svg className="w-5 h-5" 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>
</div>
</div>
</div>
)}
<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、店铺、场景类别、作者..."
/>
{/* 筛选面板组件 */}
<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}
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)
fetchMaterials()
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>
)
}