1659 lines
66 KiB
TypeScript
1659 lines
66 KiB
TypeScript
'use client'
|
||
|
||
import { useEffect, useState, useRef, useCallback, useDeferredValue, useMemo } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter, usePathname } from 'next/navigation'
|
||
import gsap from 'gsap'
|
||
import { useGSAP } from '@gsap/react'
|
||
import UploadModal from '@/components/UploadModal'
|
||
import MaterialsHeader from '@/components/board/MaterialsHeader'
|
||
import BrandFluidField from '@/components/board/BrandFluidField'
|
||
import RankingPanel from '@/components/RankingPanel'
|
||
import AIChatWidget from '@/components/AIChatWidget'
|
||
import VideoPlayerModal from '@/components/VideoPlayerModal'
|
||
import ToastBubble from '@/components/ToastBubble'
|
||
import { useToast } from '@/hooks/useToast'
|
||
import { parseDownloadError } from '@/lib/download-errors'
|
||
import { validateAndTriggerDownload } from '@/lib/client-download'
|
||
import { parseApiError, handleError, parseFetchError } from '@/lib/error-handler'
|
||
import {
|
||
normalizeVideoMaterialType,
|
||
isVideoMaterialType as isVideoMaterialTypeValue,
|
||
shouldPollVideoTranscode,
|
||
} from '@/lib/video-material-types'
|
||
import { buildPublicMaterialShareUrl } from '@/lib/public-app-url'
|
||
import { normalizeMaterialPreviewUrl, pickFirstMaterialPreviewUrl } from '@/lib/material-preview-url'
|
||
// 首页拆分组件
|
||
import { SearchBar, FilterPanel, LoadMoreTrigger, MaterialGrid } from '@/components/home'
|
||
|
||
gsap.registerPlugin(useGSAP)
|
||
|
||
const focusRing = 'focus:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2 focus-visible:ring-offset-[#fafafa]'
|
||
|
||
interface Material {
|
||
id: string
|
||
userId: string
|
||
previewUrl: string
|
||
productModel: string
|
||
categoryName: string
|
||
sceneName: string
|
||
platformName?: string // 平台分类
|
||
productCategoryName?: string // 品类分类
|
||
materialType: string
|
||
isAiGenerated?: boolean
|
||
taskDetailSourceType?: string | null
|
||
renderer: string
|
||
authorName: string
|
||
zipFileName: string
|
||
zipFileSize: string | number
|
||
downloadCount: number
|
||
likeCount: number
|
||
isLiked: boolean
|
||
isFavorited: boolean // 收藏状态
|
||
createdAt: string
|
||
displayImages?: string[] // 展示图片数组(详情页/模特类型)
|
||
videoSourceUrl?: string | null // 原始视频URL
|
||
videoPreview540Url?: string | null // 720p预览视频URL(字段名保持540Url以兼容数据库)
|
||
videoPreviewClipUrl?: string | null // 视频片段URL
|
||
videoCoverUrl?: string | null // 视频封面URL
|
||
videoProcessingStatus?: string | null // 视频处理状态
|
||
videoPreviewBlocked?: boolean
|
||
videoPreviewUnavailableMessage?: string | null
|
||
videoPreviewRemainingDays?: number | null
|
||
videoDuration?: number | null // 视频时长(秒)
|
||
transcodeLastError?: string | null // 转码错误信息
|
||
transcodeAttempts?: number | null // 转码尝试次数
|
||
transcodeProgress?: number | null // 转码进度百分比
|
||
transcodeStage?: string | null // 转码阶段:preview/clip/cover
|
||
transcodeQueueWaitingIndex?: number | null // 队列等待索引
|
||
transcodeQueueWaitingTotal?: number | null // 队列等待总数
|
||
transcodeQueueProcessingIds?: string[] // 正在处理的ID列表
|
||
transcodeQueueMaxConcurrent?: number | null // 最大并发数
|
||
user: {
|
||
username: string
|
||
avatar?: string | null
|
||
}
|
||
}
|
||
|
||
interface FacetItem {
|
||
id: string
|
||
name: string
|
||
count: number
|
||
}
|
||
|
||
interface MonthFacetItem {
|
||
value: string
|
||
label: string
|
||
count: number
|
||
}
|
||
|
||
interface FacetTotals {
|
||
materialType: number
|
||
category: number
|
||
scene: number
|
||
platform: number
|
||
productCategory: number
|
||
month: number
|
||
}
|
||
|
||
const EMPTY_FACET_TOTALS: FacetTotals = {
|
||
materialType: 0,
|
||
category: 0,
|
||
scene: 0,
|
||
platform: 0,
|
||
productCategory: 0,
|
||
month: 0,
|
||
}
|
||
|
||
export default function Home() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const pathname = usePathname()
|
||
const pageRef = useRef<HTMLDivElement>(null)
|
||
const [materials, setMaterials] = useState<Material[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [facetsLoading, setFacetsLoading] = useState(true)
|
||
const [showUploadModal, setShowUploadModal] = useState(false)
|
||
const [searchKeyword, setSearchKeyword] = useState('')
|
||
const [selectedCategory, setSelectedCategory] = useState('')
|
||
const [selectedScene, setSelectedScene] = useState('')
|
||
const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选,默认展示全部
|
||
// const [selectedMaterialType, setSelectedMaterialType] = useState('3D场景') // 素材类型筛选,默认展示3D场景(已注释,改为默认展示全部)
|
||
const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选
|
||
const [selectedLivePlatform, setSelectedLivePlatform] = useState('')
|
||
const [liveDateFrom, setLiveDateFrom] = useState('')
|
||
const [liveDateTo, setLiveDateTo] = useState('')
|
||
const [selectedProductCategory, setSelectedProductCategory] = useState('') // 品类筛选
|
||
const [categories, setCategories] = useState<FacetItem[]>([])
|
||
const [scenes, setScenes] = useState<FacetItem[]>([])
|
||
const [platforms, setPlatforms] = useState<FacetItem[]>([])
|
||
const [productCategories, setProductCategories] = useState<FacetItem[]>([])
|
||
const [isFilterCollapsed, setIsFilterCollapsed] = useState(true) // 筛选区域折叠状态,默认折叠
|
||
// 素材类型列表(已分类排序)
|
||
const [materialTypes, setMaterialTypes] = useState<{ value: string; label: string; count?: number }[]>([])
|
||
const [materialTypeCounts, setMaterialTypeCounts] = useState<Record<string, number>>({})
|
||
const [isTransitionLoading, setIsTransitionLoading] = useState(false)
|
||
const [downloadingId, setDownloadingId] = useState<string | null>(null)
|
||
const [downloadProgress, setDownloadProgress] = useState(0)
|
||
const [selectedMonth, setSelectedMonth] = useState('')
|
||
const [months, setMonths] = useState<MonthFacetItem[]>([])
|
||
const [sortBy, setSortBy] = useState('createdAt') // createdAt | downloadCount
|
||
const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc') // 排序方向:desc 倒序(默认)| asc 正序
|
||
const [previewImage, setPreviewImage] = useState<{ url: string; title: string } | null>(null)
|
||
const [showVideoModal, setShowVideoModal] = useState(false)
|
||
const [videoSrc, setVideoSrc] = useState<string | null>(null)
|
||
const transcodePollTimers = useRef<Map<string, NodeJS.Timeout>>(new Map())
|
||
const transcodePollingFlags = useRef<Map<string, boolean>>(new Map()) // 标记是否正在轮询
|
||
const fetchMaterialsRef = useRef<typeof fetchMaterials | null>(null) // 存储fetchMaterials函数引用
|
||
const materialsRequestIdRef = useRef(0)
|
||
const materialsAbortRef = useRef<AbortController | null>(null)
|
||
const facetsRequestIdRef = useRef(0)
|
||
const facetsAbortRef = useRef<AbortController | null>(null)
|
||
|
||
// 排队和单线程转码可能超过30分钟,不能按固定时长误停正常轮询。
|
||
// done/failed 会主动停止;素材离开当前列表时立即清理对应定时器。
|
||
useEffect(() => {
|
||
const currentMaterialIds = new Set(materials.map(m => m.id))
|
||
transcodePollTimers.current.forEach((timer, materialId) => {
|
||
if (!currentMaterialIds.has(materialId)) {
|
||
clearTimeout(timer)
|
||
transcodePollTimers.current.delete(materialId)
|
||
transcodePollingFlags.current.delete(materialId)
|
||
}
|
||
})
|
||
}, [materials])
|
||
|
||
// 音频播放状态
|
||
const audioRef = useRef<HTMLAudioElement | null>(null)
|
||
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null)
|
||
const [audioProgress, setAudioProgress] = useState(0) // 0-100
|
||
const [audioDuration, setAudioDuration] = useState(0) // 音频总时长(秒)
|
||
const [audioCurrentTime, setAudioCurrentTime] = useState(0) // 当前播放时间(秒)
|
||
const [audioElement, setAudioElement] = useState<HTMLAudioElement | null>(null) // 用于AudioVisualizer
|
||
const [currentMaterialsTotalCount, setCurrentMaterialsTotalCount] = useState(0)
|
||
const [facetTotals, setFacetTotals] = useState<FacetTotals>(EMPTY_FACET_TOTALS)
|
||
// 分页+懒加载相关状态
|
||
const [displayedCount, setDisplayedCount] = useState(24) // 当前显示的素材数量
|
||
const [currentPage, setCurrentPage] = useState(1) // 当前加载到第几页
|
||
const [hasMore, setHasMore] = useState(true) // 是否还有更多数据
|
||
const [isLoadingMore, setIsLoadingMore] = useState(false) // 是否正在加载更多
|
||
const pageSize = 20
|
||
const [shareUrl, setShareUrl] = useState<string | null>(null)
|
||
const [showShareModal, setShowShareModal] = useState(false)
|
||
const [showShareNotification, setShowShareNotification] = useState(false)
|
||
const themeColor = '#1a1c1d'
|
||
const [favoriteNotification, setFavoriteNotification] = useState<string | null>(null) // 收藏操作提示
|
||
const { toast, showToast } = useToast() // 统一的Toast提示
|
||
// 实时搜索:使用 useDeferredValue 实现非阻塞搜索
|
||
const deferredSearchKeyword = useDeferredValue(searchKeyword)
|
||
const isSearchPending = searchKeyword !== deferredSearchKeyword
|
||
|
||
const materialTypesWithCounts = useMemo(
|
||
() =>
|
||
materialTypes.map((type) => ({
|
||
...type,
|
||
count: materialTypeCounts[type.value] || 0,
|
||
})),
|
||
[materialTypes, materialTypeCounts],
|
||
)
|
||
|
||
const buildFilterParams = useCallback(
|
||
(options: { includeSort?: boolean; includePaging?: boolean; page?: number } = {}) => {
|
||
const { includeSort = true, includePaging = false, page = 1 } = options
|
||
const params = new URLSearchParams()
|
||
const searchValue = deferredSearchKeyword.trim()
|
||
|
||
if (searchValue) params.append('search', searchValue)
|
||
if (selectedCategory) params.append('category', selectedCategory)
|
||
if (selectedScene) params.append('scene', selectedScene)
|
||
if (selectedMaterialType) params.append('materialType', selectedMaterialType)
|
||
if (selectedPlatform) params.append('platform', selectedPlatform)
|
||
if (selectedLivePlatform) params.append('livePlatform', selectedLivePlatform)
|
||
if (liveDateFrom) params.append('liveDateFrom', liveDateFrom)
|
||
if (liveDateTo) params.append('liveDateTo', liveDateTo)
|
||
if (selectedProductCategory) params.append('productCategory', selectedProductCategory)
|
||
if (selectedMonth) params.append('month', selectedMonth)
|
||
if (includeSort) {
|
||
if (sortBy) params.append('sortBy', sortBy)
|
||
if (sortOrder) params.append('sortOrder', sortOrder)
|
||
}
|
||
if (includePaging) {
|
||
params.append('page', page.toString())
|
||
params.append('pageSize', pageSize.toString())
|
||
params.append('deferTotal', '1')
|
||
}
|
||
|
||
return params
|
||
},
|
||
[
|
||
deferredSearchKeyword,
|
||
selectedCategory,
|
||
selectedScene,
|
||
selectedMaterialType,
|
||
selectedPlatform,
|
||
selectedProductCategory,
|
||
selectedMonth,
|
||
sortBy,
|
||
sortOrder,
|
||
pageSize,
|
||
],
|
||
)
|
||
|
||
// 实时搜索处理函数 - 输入立即更新,搜索由 useDeferredValue 控制
|
||
const handleSearchChange = useCallback((value: string) => {
|
||
setSearchKeyword(value)
|
||
}, [])
|
||
|
||
const handleCategoryChange = useCallback((value: string) => {
|
||
setSelectedCategory(value)
|
||
}, [])
|
||
|
||
// 移除页面卸载时保存状态的逻辑(避免滚动问题)
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login')
|
||
}
|
||
}, [status, router])
|
||
|
||
// 从URL参数恢复筛选状态(在组件初始化时执行一次)
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
|
||
const params = new URLSearchParams(window.location.search)
|
||
const materialType = params.get('materialType')
|
||
const category = params.get('category')
|
||
const scene = params.get('scene')
|
||
const platform = params.get('platform')
|
||
const productCategory = params.get('productCategory')
|
||
const month = params.get('month')
|
||
const search = params.get('search')
|
||
const sort = params.get('sortBy')
|
||
const sortOrderParam = params.get('sortOrder')
|
||
|
||
if (materialType) setSelectedMaterialType(normalizeVideoMaterialType(materialType))
|
||
if (category) setSelectedCategory(category)
|
||
if (scene) setSelectedScene(scene)
|
||
if (platform) setSelectedPlatform(platform)
|
||
if (productCategory) setSelectedProductCategory(productCategory)
|
||
if (month) setSelectedMonth(month)
|
||
if (search) setSearchKeyword(search)
|
||
if (sort) setSortBy(sort)
|
||
if (sortOrderParam === 'asc' || sortOrderParam === 'desc') setSortOrder(sortOrderParam)
|
||
}, [])
|
||
|
||
// 同步筛选状态到URL参数
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
|
||
const params = new URLSearchParams()
|
||
const searchValue = deferredSearchKeyword.trim()
|
||
if (searchValue) params.set('search', searchValue)
|
||
if (selectedMaterialType) params.set('materialType', selectedMaterialType) // 只有选择了素材类型才添加参数
|
||
if (selectedCategory) params.set('category', selectedCategory)
|
||
if (selectedScene) params.set('scene', selectedScene)
|
||
if (selectedPlatform) params.set('platform', selectedPlatform)
|
||
if (selectedLivePlatform) params.set('livePlatform', selectedLivePlatform)
|
||
if (liveDateFrom) params.set('liveDateFrom', liveDateFrom)
|
||
if (liveDateTo) params.set('liveDateTo', liveDateTo)
|
||
if (selectedProductCategory) params.set('productCategory', selectedProductCategory)
|
||
if (selectedMonth) params.set('month', selectedMonth)
|
||
if (sortBy && sortBy !== 'createdAt') params.set('sortBy', sortBy)
|
||
if (sortOrder && sortOrder !== 'desc') params.set('sortOrder', sortOrder)
|
||
|
||
const newUrl = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname
|
||
window.history.replaceState({}, '', newUrl)
|
||
}, [deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
|
||
|
||
// 加载当前用户可见的素材类型
|
||
useEffect(() => {
|
||
const loadViewableTypes = async () => {
|
||
if (status !== 'authenticated') return
|
||
try {
|
||
const mapMaterialType = (t: string) => {
|
||
// 映射老类型到新类型
|
||
if (t === '3DMAX') return '3D场景'
|
||
if (t === '产品全案') return '任务-详情'
|
||
if (t === '详情页') return '详情模板'
|
||
return normalizeVideoMaterialType(t)
|
||
}
|
||
const res = await fetch('/api/materials/viewable-types')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
const types = (data.materialTypes || []) as string[]
|
||
const normalizeTypes = (arr: string[]) =>
|
||
Array.from(
|
||
new Set(
|
||
arr
|
||
.map(mapMaterialType)
|
||
.filter((t) => t && t !== '全部')
|
||
)
|
||
)
|
||
const source = normalizeTypes(types)
|
||
const finalList = source.map((t) => ({
|
||
value: t,
|
||
label: t,
|
||
}))
|
||
const hasSelected = finalList.some((t) => t.value === selectedMaterialType)
|
||
// const preferredDefault = '3D场景' // 已注释,改为默认展示全部
|
||
// const hasPreferred = finalList.some((t) => t.value === preferredDefault)
|
||
if (!hasSelected && selectedMaterialType) { // 如果当前选中的类型不在列表中,且有选中值,则重置为空(全部)
|
||
setSelectedMaterialType('')
|
||
}
|
||
const mapped = finalList
|
||
setMaterialTypes(mapped)
|
||
} else {
|
||
setMaterialTypes([])
|
||
if (selectedMaterialType) {
|
||
setSelectedMaterialType('')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.error('获取可见素材类型失败:', error)
|
||
setMaterialTypes([])
|
||
if (selectedMaterialType) {
|
||
setSelectedMaterialType('')
|
||
}
|
||
}
|
||
}
|
||
loadViewableTypes()
|
||
}, [status])
|
||
|
||
// 恢复滚动位置和页面状态(从详情页返回时)
|
||
// 使用 pathname 作为依赖,当路由变为首页时触发恢复
|
||
const hasRestoredRef = useRef(false)
|
||
useEffect(() => {
|
||
if (typeof window === 'undefined') return
|
||
// 只在首页路径时执行
|
||
if (pathname !== '/materials') return
|
||
// 避免重复恢复
|
||
if (hasRestoredRef.current) return
|
||
|
||
try {
|
||
const savedScrollPosition = sessionStorage.getItem('home_scroll_position')
|
||
const savedCache = sessionStorage.getItem('home_materials_cache')
|
||
|
||
if (savedCache) {
|
||
const cache = JSON.parse(savedCache)
|
||
const cacheAge = Date.now() - (cache.timestamp || 0)
|
||
const CACHE_EXPIRY = 5 * 60 * 1000 // 5分钟
|
||
const cachedMaterials = Array.isArray(cache.materials) ? cache.materials : []
|
||
|
||
// 如果缓存未过期,恢复状态
|
||
if (cacheAge < CACHE_EXPIRY && cachedMaterials.length > 0) {
|
||
// 恢复筛选条件
|
||
if (cache.selectedCategory) setSelectedCategory(cache.selectedCategory)
|
||
if (cache.selectedScene) setSelectedScene(cache.selectedScene)
|
||
if (cache.selectedMaterialType) setSelectedMaterialType(normalizeVideoMaterialType(cache.selectedMaterialType))
|
||
if (cache.selectedPlatform) setSelectedPlatform(cache.selectedPlatform)
|
||
if (cache.selectedProductCategory) setSelectedProductCategory(cache.selectedProductCategory)
|
||
if (cache.selectedMonth) setSelectedMonth(cache.selectedMonth)
|
||
if (cache.sortBy) setSortBy(cache.sortBy)
|
||
if (cache.sortOrder) setSortOrder(cache.sortOrder)
|
||
if (cache.searchKeyword) setSearchKeyword(cache.searchKeyword)
|
||
|
||
// 恢复素材数据和显示数量
|
||
if (cachedMaterials.length > 0) {
|
||
setMaterials(cachedMaterials)
|
||
setDisplayedCount(cache.displayedCount || cachedMaterials.length)
|
||
setCurrentPage(cache.currentPage || 1)
|
||
}
|
||
|
||
// 立即清除loading状态(使用缓存时不需要加载)
|
||
setLoading(false)
|
||
|
||
// 恢复滚动位置(延迟执行,确保内容已渲染)
|
||
if (savedScrollPosition) {
|
||
// 等待内容渲染完成后再恢复滚动位置
|
||
const restoreScroll = () => {
|
||
const scrollY = parseInt(savedScrollPosition, 10)
|
||
if (scrollY > 0) {
|
||
// 多次尝试恢复,确保内容已完全渲染
|
||
let attempts = 0
|
||
const tryScroll = () => {
|
||
attempts++
|
||
window.scrollTo({
|
||
top: scrollY,
|
||
behavior: 'auto'
|
||
})
|
||
// 如果滚动位置不对,且尝试次数少于5次,继续尝试
|
||
if (Math.abs(window.scrollY - scrollY) > 10 && attempts < 5) {
|
||
setTimeout(tryScroll, 100)
|
||
} else {
|
||
// 清除已恢复的缓存
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
sessionStorage.removeItem('home_materials_cache')
|
||
hasRestoredRef.current = true
|
||
}
|
||
}
|
||
// 使用多个 requestAnimationFrame 确保DOM已渲染
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
setTimeout(tryScroll, 50)
|
||
})
|
||
})
|
||
} else {
|
||
// 没有滚动位置,直接清除缓存
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
sessionStorage.removeItem('home_materials_cache')
|
||
hasRestoredRef.current = true
|
||
}
|
||
}
|
||
restoreScroll()
|
||
} else {
|
||
// 没有滚动位置,直接清除缓存
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
sessionStorage.removeItem('home_materials_cache')
|
||
hasRestoredRef.current = true
|
||
}
|
||
|
||
return // 使用缓存,不重新获取数据
|
||
} else {
|
||
// 缓存过期,清除
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
sessionStorage.removeItem('home_materials_cache')
|
||
// 缓存过期时,不设置 hasRestoredRef,让正常的数据获取逻辑执行
|
||
}
|
||
} else if (savedScrollPosition) {
|
||
// 只有滚动位置,没有缓存数据,恢复滚动位置(但不阻止数据获取)
|
||
const scrollY = parseInt(savedScrollPosition, 10)
|
||
if (scrollY > 0) {
|
||
let attempts = 0
|
||
const tryScroll = () => {
|
||
attempts++
|
||
window.scrollTo({
|
||
top: scrollY,
|
||
behavior: 'auto'
|
||
})
|
||
// 如果滚动位置不对,且尝试次数少于5次,继续尝试
|
||
if (Math.abs(window.scrollY - scrollY) > 10 && attempts < 5) {
|
||
setTimeout(tryScroll, 100)
|
||
} else {
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
// 不设置 hasRestoredRef,让数据正常获取
|
||
}
|
||
}
|
||
requestAnimationFrame(() => {
|
||
requestAnimationFrame(() => {
|
||
setTimeout(tryScroll, 50)
|
||
})
|
||
})
|
||
} else {
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
// 不设置 hasRestoredRef,让数据正常获取
|
||
}
|
||
}
|
||
// 如果没有缓存也没有滚动位置,不设置 hasRestoredRef,让正常的数据获取逻辑执行
|
||
} catch (e) {
|
||
console.warn('[Home] Failed to restore state from sessionStorage:', e)
|
||
sessionStorage.removeItem('home_scroll_position')
|
||
sessionStorage.removeItem('home_materials_cache')
|
||
// 出错时,不设置 hasRestoredRef,让正常的数据获取逻辑执行
|
||
}
|
||
}, [pathname]) // 当路由变化到首页时执行
|
||
|
||
// 当从其他页面返回首页时,重置恢复标记
|
||
useEffect(() => {
|
||
if (pathname === '/materials') {
|
||
// 延迟重置,确保恢复逻辑已执行
|
||
const timer = setTimeout(() => {
|
||
hasRestoredRef.current = false
|
||
}, 1000)
|
||
return () => clearTimeout(timer)
|
||
}
|
||
}, [pathname])
|
||
|
||
// 当筛选条件变化时,重新获取素材列表(延迟执行,避免初始化时重复调用)
|
||
useEffect(() => {
|
||
// 只有在已登录状态下才获取素材
|
||
if (status !== 'authenticated') return
|
||
|
||
// 如果已从缓存恢复,且恢复标记还在,不重新获取
|
||
// 注意:这里不删除缓存,因为恢复逻辑会自己处理
|
||
if (hasRestoredRef.current) {
|
||
// 恢复完成后,清除恢复标记,允许后续筛选变化时正常获取
|
||
const timer = setTimeout(() => {
|
||
hasRestoredRef.current = false
|
||
}, 2000) // 给恢复逻辑足够的时间
|
||
return () => clearTimeout(timer)
|
||
}
|
||
|
||
// 延迟执行,确保状态已恢复
|
||
const timer = setTimeout(() => {
|
||
fetchMaterials(1, false)
|
||
}, 100)
|
||
return () => clearTimeout(timer)
|
||
}, [status, deferredSearchKeyword, selectedMaterialType, selectedCategory, selectedScene, selectedPlatform, selectedProductCategory, selectedMonth, sortBy, sortOrder])
|
||
|
||
// 移除筛选条件保存逻辑(避免滚动问题)
|
||
|
||
// 获取素材列表(支持分页和追加模式)
|
||
const fetchMaterials = useCallback(
|
||
async (page = 1, append = false) => {
|
||
const requestId = ++materialsRequestIdRef.current
|
||
materialsAbortRef.current?.abort()
|
||
const controller = new AbortController()
|
||
materialsAbortRef.current = controller
|
||
|
||
try {
|
||
// 修复:只在首次加载(无搜索词且无结果)时显示全屏加载
|
||
// 搜索时只使用 isTransitionLoading,避免整个页面被替换导致 SearchBar 丢失焦点
|
||
const isInitialLoad = !append && page === 1 && materials.length === 0 && !deferredSearchKeyword
|
||
if (isInitialLoad) {
|
||
setLoading(true)
|
||
} else if (!append) {
|
||
setIsTransitionLoading(true)
|
||
}
|
||
if (!append) setHasMore(true)
|
||
|
||
const params = buildFilterParams({ includePaging: true, page })
|
||
|
||
const url = `/api/materials${params.toString() ? '?' + params.toString() : ''}`
|
||
const response = await fetch(url, { signal: controller.signal })
|
||
if (!response.ok) {
|
||
throw new Error(`素材加载失败 (${response.status})`)
|
||
}
|
||
if (requestId === materialsRequestIdRef.current) {
|
||
const data = await response.json()
|
||
// 清理素材 ID,并为预览图补齐统一 fallback
|
||
const materialsList = ((data.materials || data) as Material[])
|
||
.flatMap((e): Material[] => {
|
||
if (!e.id || typeof e.id !== 'string') return []
|
||
const trimmedId = e.id.trim()
|
||
if (!trimmedId) return []
|
||
|
||
const displayImages = (Array.isArray(e.displayImages) ? e.displayImages : [])
|
||
.map(normalizeMaterialPreviewUrl)
|
||
.filter((url): url is string => Boolean(url))
|
||
const videoSourceUrl = normalizeMaterialPreviewUrl(e.videoSourceUrl)
|
||
const videoPreview540Url = normalizeMaterialPreviewUrl(e.videoPreview540Url)
|
||
const videoPreviewClipUrl = normalizeMaterialPreviewUrl(e.videoPreviewClipUrl)
|
||
const videoCoverUrl = normalizeMaterialPreviewUrl(e.videoCoverUrl)
|
||
// 只允许非空字符串进入预览 URL,避免历史异常布尔值挡住后续合法地址。
|
||
const previewUrl = pickFirstMaterialPreviewUrl(
|
||
e.previewUrl,
|
||
videoCoverUrl,
|
||
...displayImages,
|
||
) || ''
|
||
|
||
return [{
|
||
...e,
|
||
id: trimmedId,
|
||
previewUrl,
|
||
displayImages,
|
||
videoSourceUrl,
|
||
videoPreview540Url,
|
||
videoPreviewClipUrl,
|
||
videoCoverUrl,
|
||
}]
|
||
})
|
||
|
||
let appendedItems: Material[] = materialsList
|
||
if (append) {
|
||
setMaterials((prev) => {
|
||
const existingIds = new Set(prev.map((item) => item.id))
|
||
const deduped = materialsList.filter((item) => !existingIds.has(item.id))
|
||
appendedItems = deduped
|
||
if (deduped.length === 0) return prev
|
||
return [...prev, ...deduped]
|
||
})
|
||
} else {
|
||
setMaterials(materialsList)
|
||
setDisplayedCount(materialsList.length)
|
||
}
|
||
|
||
const appendedCount = append ? appendedItems.length : materialsList.length
|
||
|
||
setCurrentPage(page)
|
||
if (typeof data.hasMore === 'boolean') {
|
||
setHasMore(data.hasMore)
|
||
} else {
|
||
const totalPages = data.totalPages || 1
|
||
setHasMore(appendedCount > 0 && page < totalPages)
|
||
}
|
||
if (typeof data.total === 'number') {
|
||
setCurrentMaterialsTotalCount(data.total)
|
||
} else if (typeof data.totalCount === 'number') {
|
||
setCurrentMaterialsTotalCount(data.totalCount)
|
||
}
|
||
|
||
// 对需要转码的视频素材启动轮询
|
||
appendedItems.forEach((material, index) => {
|
||
if (shouldPollVideoTranscode(material.materialType, material.videoProcessingStatus)) {
|
||
// 避免一页多个视频在同一毫秒冲击状态接口。
|
||
startTranscodePoll(material, Math.min(index * 250, 3000))
|
||
}
|
||
})
|
||
|
||
return appendedCount
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return 0
|
||
console.error('Error fetching materials:', error)
|
||
if (requestId === materialsRequestIdRef.current) {
|
||
showToast(error instanceof Error ? error.message : '素材加载失败', { variant: 'error' })
|
||
}
|
||
} finally {
|
||
if (!append && requestId === materialsRequestIdRef.current) {
|
||
setLoading(false)
|
||
setIsTransitionLoading(false)
|
||
}
|
||
if (materialsAbortRef.current === controller) materialsAbortRef.current = null
|
||
}
|
||
return 0
|
||
},
|
||
[materials.length, buildFilterParams, showToast]
|
||
)
|
||
|
||
// 更新fetchMaterialsRef
|
||
useEffect(() => {
|
||
fetchMaterialsRef.current = fetchMaterials
|
||
}, [fetchMaterials])
|
||
|
||
// 判断是否为视频类型素材
|
||
const isVideoMaterial = useCallback((materialType: string): boolean => {
|
||
return isVideoMaterialTypeValue(materialType)
|
||
}, [])
|
||
|
||
// 停止转码轮询
|
||
const stopTranscodePoll = useCallback((materialId: string) => {
|
||
const timer = transcodePollTimers.current.get(materialId)
|
||
if (timer) {
|
||
clearTimeout(timer)
|
||
transcodePollTimers.current.delete(materialId)
|
||
}
|
||
transcodePollingFlags.current.delete(materialId)
|
||
}, [])
|
||
|
||
// 启动转码轮询
|
||
const startTranscodePoll = useCallback((material: Material, delay: number = 5000) => {
|
||
if (!isVideoMaterial(material.materialType)) return
|
||
if (!material.id || typeof material.id !== 'string') {
|
||
console.error('[TranscodePoll] Invalid material ID (empty or not string):', material.id)
|
||
return
|
||
}
|
||
|
||
const materialId = material.id.trim()
|
||
if (!materialId || materialId.length < 10 || materialId.length > 100) {
|
||
console.error('[TranscodePoll] Invalid material ID format:', material.id, 'Length:', materialId.length)
|
||
return
|
||
}
|
||
|
||
// 如果已完成,停止轮询
|
||
if (material.videoProcessingStatus === 'done') {
|
||
stopTranscodePoll(materialId)
|
||
return
|
||
}
|
||
|
||
// 如果已经在轮询,不重复启动
|
||
if (transcodePollingFlags.current.get(materialId)) {
|
||
return
|
||
}
|
||
|
||
const poll = async () => {
|
||
try {
|
||
const response = await fetch(`/api/materials/transcode?materialId=${encodeURIComponent(materialId)}`)
|
||
if (!response.ok) {
|
||
const errorData = await response.json().catch(() => ({}))
|
||
console.error(`[TranscodePoll] GET request failed for material ${materialId}:`, response.status, errorData)
|
||
if (response.status === 429) {
|
||
const retryAfterSeconds = Number(response.headers.get('Retry-After') || '15')
|
||
const retryDelay = Number.isFinite(retryAfterSeconds)
|
||
? Math.max(5000, Math.min(retryAfterSeconds * 1000, 60000))
|
||
: 15000
|
||
const timer = setTimeout(poll, retryDelay)
|
||
transcodePollTimers.current.set(materialId, timer)
|
||
return
|
||
}
|
||
if ([400, 401, 403, 404].includes(response.status)) {
|
||
stopTranscodePoll(materialId)
|
||
return
|
||
}
|
||
throw new Error(`status ${response.status}: ${errorData.error || 'Unknown error'}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
|
||
// 更新素材状态
|
||
setMaterials((prev) =>
|
||
prev.map((m) =>
|
||
(m.id?.trim() || m.id) === materialId || m.id === material.id
|
||
? {
|
||
...m,
|
||
videoProcessingStatus: data.status,
|
||
// 状态接口只有 has* 布尔标志,真实 URL 由完成后的列表刷新取得。
|
||
videoPreview540Url: normalizeMaterialPreviewUrl(m.videoPreview540Url),
|
||
videoPreviewClipUrl: normalizeMaterialPreviewUrl(m.videoPreviewClipUrl),
|
||
videoCoverUrl: normalizeMaterialPreviewUrl(m.videoCoverUrl),
|
||
videoDuration: data.duration ?? m.videoDuration,
|
||
transcodeAttempts: data.attempts ?? m.transcodeAttempts,
|
||
transcodeProgress: data.progress ?? m.transcodeProgress,
|
||
transcodeStage: data.stage ?? m.transcodeStage,
|
||
transcodeQueueWaitingIndex: data.queue?.waitingIndex ?? null,
|
||
transcodeQueueWaitingTotal: data.queue?.waitingTotal ?? null,
|
||
transcodeQueueProcessingIds: data.queue?.processingIds ?? [],
|
||
transcodeQueueMaxConcurrent: data.queue?.maxConcurrent ?? m.transcodeQueueMaxConcurrent,
|
||
transcodeLastError: data.lastError ?? m.transcodeLastError,
|
||
}
|
||
: m
|
||
)
|
||
)
|
||
|
||
// 如果已完成但缺少预览URL,刷新列表
|
||
if (data.status === 'done' && !material.videoPreviewClipUrl && !material.videoPreview540Url) {
|
||
console.log('[TranscodePoll] Transcode done but missing preview URLs, refreshing material list...')
|
||
fetchMaterialsRef.current?.(1, false)
|
||
}
|
||
|
||
// 根据状态决定是否继续轮询
|
||
if (data.status === 'done') {
|
||
stopTranscodePoll(materialId)
|
||
setTimeout(() => {
|
||
fetchMaterialsRef.current?.(1, false)
|
||
}, 500)
|
||
} else if (data.status === 'failed') {
|
||
stopTranscodePoll(materialId)
|
||
} else {
|
||
// 转码是分钟级后台任务;15秒轮询既能及时刷新,也不会让一页多卡片打爆状态接口。
|
||
const timer = setTimeout(poll, 15000)
|
||
transcodePollTimers.current.set(materialId, timer)
|
||
}
|
||
} catch (error) {
|
||
console.error(`[TranscodePoll] Poll error for material ${materialId}:`, error)
|
||
// 出错后8秒重试
|
||
const timer = setTimeout(poll, 8000)
|
||
transcodePollTimers.current.set(materialId, timer)
|
||
}
|
||
}
|
||
|
||
// 标记为正在轮询
|
||
transcodePollingFlags.current.set(materialId, true)
|
||
|
||
// 延迟启动轮询
|
||
const timer = setTimeout(poll, delay)
|
||
transcodePollTimers.current.set(materialId, timer)
|
||
}, [isVideoMaterial, stopTranscodePoll])
|
||
|
||
// 组件卸载时清理所有转码轮询
|
||
useEffect(() => {
|
||
const pollTimers = transcodePollTimers.current
|
||
const pollingFlags = transcodePollingFlags.current
|
||
return () => {
|
||
pollTimers.forEach((timer) => {
|
||
clearTimeout(timer)
|
||
})
|
||
pollTimers.clear()
|
||
pollingFlags.clear()
|
||
}
|
||
}, [])
|
||
|
||
// 音频播放控制
|
||
const handleAudioPlay = useCallback((materialId: string, audioUrl: string) => {
|
||
console.log('[Home] handleAudioPlay called:', { materialId, audioUrl })
|
||
|
||
if (!audioUrl) {
|
||
console.error('[Home] No audio URL provided for material:', materialId)
|
||
showToast('音频文件不存在', { variant: 'error' })
|
||
return
|
||
}
|
||
|
||
// 防止重复调用:如果正在加载或播放同一个音频,直接返回
|
||
if (playingAudioId === materialId && audioRef.current) {
|
||
// 如果点击的是当前正在播放的音频,则暂停
|
||
if (audioRef.current && !audioRef.current.paused) {
|
||
audioRef.current.pause()
|
||
audioRef.current = null
|
||
setAudioElement(null)
|
||
setPlayingAudioId(null)
|
||
setAudioProgress(0)
|
||
setAudioDuration(0)
|
||
setAudioCurrentTime(0)
|
||
return
|
||
}
|
||
}
|
||
|
||
// 如果正在加载另一个音频,先停止并清理
|
||
if (audioRef.current && playingAudioId !== materialId) {
|
||
// 清理所有事件监听器
|
||
const oldAudio = audioRef.current
|
||
oldAudio.pause()
|
||
oldAudio.src = ''
|
||
oldAudio.load()
|
||
audioRef.current = null
|
||
}
|
||
|
||
// 清理旧的 audio 元素引用,确保 AudioVisualizer 能正确切换到新的音频
|
||
setAudioElement(null)
|
||
|
||
// 重置状态,避免进度条闪烁
|
||
setAudioProgress(0)
|
||
setAudioDuration(0)
|
||
setAudioCurrentTime(0)
|
||
|
||
// 创建新的音频元素
|
||
const audio = new Audio(audioUrl)
|
||
audio.crossOrigin = 'anonymous' // 允许跨域访问(用于AudioVisualizer)
|
||
audio.preload = 'auto' // 预加载音频
|
||
audioRef.current = audio
|
||
|
||
// 监听加载错误
|
||
const handleError = (e: Event) => {
|
||
const error = audio.error
|
||
console.error('[Home] Audio error:', {
|
||
code: error?.code,
|
||
message: error?.message,
|
||
audioUrl,
|
||
materialId
|
||
})
|
||
showToast(`音频加载失败: ${error?.message || '未知错误'}`, { variant: 'error' })
|
||
setPlayingAudioId(null)
|
||
setAudioProgress(0)
|
||
setAudioDuration(0)
|
||
setAudioCurrentTime(0)
|
||
if (audioRef.current === audio) {
|
||
audioRef.current = null
|
||
setAudioElement(null)
|
||
}
|
||
audio.removeEventListener('error', handleError)
|
||
}
|
||
audio.addEventListener('error', handleError)
|
||
|
||
// 监听元数据加载完成(获取时长)
|
||
const handleLoadedMetadata = () => {
|
||
console.log('[Home] Audio metadata loaded, duration:', audio.duration)
|
||
if (audioRef.current === audio) {
|
||
setAudioDuration(audio.duration || 0)
|
||
}
|
||
}
|
||
audio.addEventListener('loadedmetadata', handleLoadedMetadata)
|
||
|
||
// 监听可以播放(使用canplay事件,有足够数据就开始播放,减少延迟)
|
||
const handleCanPlay = () => {
|
||
// 检查是否仍然是当前音频(防止切换音频时的竞态条件)
|
||
if (audioRef.current !== audio) {
|
||
audio.removeEventListener('canplay', handleCanPlay)
|
||
return
|
||
}
|
||
|
||
console.log('[Home] Audio can play:', audioUrl)
|
||
audio.play().then(() => {
|
||
// 再次检查是否仍然是当前音频
|
||
if (audioRef.current !== audio) {
|
||
audio.pause()
|
||
return
|
||
}
|
||
console.log('[Home] Audio playback started:', audioUrl)
|
||
setAudioElement(audio) // 设置audio元素供AudioVisualizer使用
|
||
setPlayingAudioId(materialId)
|
||
}).catch((error) => {
|
||
console.error('[Home] Audio play failed:', error, 'URL:', audioUrl)
|
||
showToast(`播放失败: ${error.message || '未知错误'}`, { variant: 'error' })
|
||
if (audioRef.current === audio) {
|
||
setPlayingAudioId(null)
|
||
audioRef.current = null
|
||
setAudioElement(null)
|
||
}
|
||
})
|
||
audio.removeEventListener('canplay', handleCanPlay)
|
||
}
|
||
audio.addEventListener('canplay', handleCanPlay)
|
||
|
||
// 如果音频已经可以播放,立即播放
|
||
if (audio.readyState >= 2) {
|
||
handleCanPlay()
|
||
}
|
||
|
||
// 监听播放进度
|
||
const handleTimeUpdate = () => {
|
||
// 检查是否仍然是当前音频
|
||
if (audioRef.current !== audio) {
|
||
// 如果不是当前音频,移除监听器
|
||
audio.removeEventListener('timeupdate', handleTimeUpdate)
|
||
return
|
||
}
|
||
// 只有当音频有时长且正在播放时才更新进度
|
||
if (audio.duration && !audio.paused) {
|
||
setAudioProgress((audio.currentTime / audio.duration) * 100)
|
||
setAudioCurrentTime(audio.currentTime)
|
||
}
|
||
}
|
||
audio.addEventListener('timeupdate', handleTimeUpdate)
|
||
|
||
// 监听播放结束
|
||
const handleEnded = () => {
|
||
if (audioRef.current === audio) {
|
||
setPlayingAudioId(null)
|
||
setAudioProgress(0)
|
||
setAudioDuration(0)
|
||
setAudioCurrentTime(0)
|
||
audioRef.current = null
|
||
setAudioElement(null)
|
||
}
|
||
audio.removeEventListener('ended', handleEnded)
|
||
}
|
||
audio.addEventListener('ended', handleEnded)
|
||
|
||
// 开始加载音频
|
||
audio.load()
|
||
}, [playingAudioId, showToast])
|
||
|
||
// 组件卸载时清理音频
|
||
useEffect(() => {
|
||
return () => {
|
||
if (audioRef.current) {
|
||
audioRef.current.pause()
|
||
audioRef.current = null
|
||
}
|
||
}
|
||
}, [])
|
||
|
||
// 分页+懒加载函数(完全参考flnxi_sucai实现)
|
||
const handleLoadMore = useCallback(async () => {
|
||
if (isLoadingMore) return
|
||
|
||
// 如果当前显示的数量小于已加载的素材数量,直接增加显示数量
|
||
if (displayedCount < materials.length) {
|
||
setIsLoadingMore(true)
|
||
setDisplayedCount(materials.length)
|
||
setIsLoadingMore(false)
|
||
return
|
||
}
|
||
|
||
// 如果没有更多数据,不再加载
|
||
if (!hasMore) return
|
||
|
||
// 加载下一页数据
|
||
setIsLoadingMore(true)
|
||
const appended = await fetchMaterials(currentPage + 1, true)
|
||
if (appended > 0) {
|
||
setDisplayedCount((prev) => prev + appended)
|
||
}
|
||
setIsLoadingMore(false)
|
||
}, [isLoadingMore, displayedCount, materials.length, hasMore, currentPage, fetchMaterials])
|
||
|
||
// IntersectionObserver 逻辑已移至 LoadMoreTrigger 组件
|
||
|
||
const fetchFacets = useCallback(async () => {
|
||
const requestId = ++facetsRequestIdRef.current
|
||
facetsAbortRef.current?.abort()
|
||
const controller = new AbortController()
|
||
facetsAbortRef.current = controller
|
||
setFacetsLoading(true)
|
||
|
||
try {
|
||
const params = buildFilterParams({ includeSort: false, includePaging: false })
|
||
const url = `/api/materials/facets${params.toString() ? '?' + params.toString() : ''}`
|
||
const response = await fetch(url, { signal: controller.signal })
|
||
if (!response.ok) throw new Error(`筛选数据加载失败 (${response.status})`)
|
||
if (requestId === facetsRequestIdRef.current) {
|
||
const data = await response.json()
|
||
setCategories(data.categories || [])
|
||
setScenes(data.scenes || [])
|
||
setPlatforms(data.platforms || [])
|
||
setProductCategories(data.productCategories || [])
|
||
setMonths(data.months || [])
|
||
setFacetTotals({ ...EMPTY_FACET_TOTALS, ...(data.totals || {}) })
|
||
if (typeof data.total === 'number') {
|
||
setCurrentMaterialsTotalCount(data.total)
|
||
}
|
||
setMaterialTypeCounts(
|
||
Object.fromEntries(
|
||
((data.materialTypes || []) as FacetItem[]).map((item) => [item.name, item.count]),
|
||
),
|
||
)
|
||
const nextCategories = (data.categories || []) as FacetItem[]
|
||
const nextScenes = (data.scenes || []) as FacetItem[]
|
||
const nextPlatforms = (data.platforms || []) as FacetItem[]
|
||
const nextProductCategories = (data.productCategories || []) as FacetItem[]
|
||
const nextMonths = (data.months || []) as MonthFacetItem[]
|
||
if (selectedCategory && !nextCategories.some((item) => item.name === selectedCategory)) {
|
||
setSelectedCategory('')
|
||
}
|
||
if (selectedScene && !nextScenes.some((item) => item.name === selectedScene)) {
|
||
setSelectedScene('')
|
||
}
|
||
if (selectedPlatform && !nextPlatforms.some((item) => item.name === selectedPlatform)) {
|
||
setSelectedPlatform('')
|
||
}
|
||
if (selectedProductCategory && !nextProductCategories.some((item) => item.name === selectedProductCategory)) {
|
||
setSelectedProductCategory('')
|
||
}
|
||
if (selectedMonth) {
|
||
const monthStillValid = nextMonths.some((item) =>
|
||
selectedMonth.length === 4 ? item.value.startsWith(`${selectedMonth}-`) : item.value === selectedMonth,
|
||
)
|
||
if (!monthStillValid) setSelectedMonth('')
|
||
}
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||
console.error('Error fetching material facets:', error)
|
||
if (requestId === facetsRequestIdRef.current) {
|
||
showToast(error instanceof Error ? error.message : '筛选数据加载失败', { variant: 'error' })
|
||
}
|
||
} finally {
|
||
if (requestId === facetsRequestIdRef.current) {
|
||
setFacetsLoading(false)
|
||
}
|
||
if (facetsAbortRef.current === controller) facetsAbortRef.current = null
|
||
}
|
||
}, [buildFilterParams, showToast])
|
||
|
||
useEffect(() => () => {
|
||
materialsAbortRef.current?.abort()
|
||
facetsAbortRef.current?.abort()
|
||
}, [])
|
||
|
||
useEffect(() => {
|
||
if (status !== 'authenticated') return
|
||
fetchFacets()
|
||
}, [status, fetchFacets])
|
||
|
||
// 根据素材类型获取角标背景颜色
|
||
const getMaterialTypeBadgeColor = (materialType: string): string => {
|
||
// 判断是否为视频类型
|
||
const isVideoType = isVideoMaterial(materialType)
|
||
|
||
if (materialType === '3D场景' || materialType === '3DMAX') {
|
||
return 'bg-red-500'
|
||
} else if (isVideoType) {
|
||
return 'bg-blue-600'
|
||
} else if (materialType === '音乐') {
|
||
return 'bg-purple-600'
|
||
} else if (materialType === 'C4D模型') {
|
||
return 'bg-blue-500'
|
||
} else if (materialType === '详情页' || materialType === '详情模板' || materialType === '矢量文件') {
|
||
return 'bg-amber-500'
|
||
} else {
|
||
return 'bg-gray-700'
|
||
}
|
||
}
|
||
|
||
// 格式化文件大小
|
||
const formatFileSize = (bytes: string | number): string => {
|
||
const size = typeof bytes === 'string' ? parseInt(bytes) : bytes
|
||
if (size === 0) return '未知'
|
||
const units = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(size) / Math.log(1024))
|
||
return `${(size / Math.pow(1024, i)).toFixed(2)} ${units[i]}`
|
||
}
|
||
|
||
// 分享功能
|
||
const handleShare = async (materialId: string) => {
|
||
const url = buildPublicMaterialShareUrl(materialId)
|
||
setShareUrl(url)
|
||
try {
|
||
await navigator.clipboard.writeText(url)
|
||
setShowShareNotification(true)
|
||
setTimeout(() => setShowShareNotification(false), 2000)
|
||
} catch (error) {
|
||
setShowShareModal(true)
|
||
}
|
||
}
|
||
|
||
const copyShareUrl = async () => {
|
||
if (!shareUrl) return
|
||
|
||
try {
|
||
await navigator.clipboard.writeText(shareUrl)
|
||
setShowShareNotification(true)
|
||
setTimeout(() => setShowShareNotification(false), 2000)
|
||
setShowShareModal(false)
|
||
} catch (error) {
|
||
console.error('复制失败,尝试降级方案:', error)
|
||
// 降级方案:使用传统方法复制
|
||
const textarea = document.createElement('textarea')
|
||
textarea.value = shareUrl
|
||
textarea.style.position = 'fixed'
|
||
textarea.style.left = '-999999px'
|
||
textarea.style.top = '-999999px'
|
||
document.body.appendChild(textarea)
|
||
textarea.focus()
|
||
textarea.select()
|
||
try {
|
||
const successful = document.execCommand('copy')
|
||
if (successful) {
|
||
setShowShareNotification(true)
|
||
setTimeout(() => setShowShareNotification(false), 2000)
|
||
setShowShareModal(false)
|
||
} else {
|
||
showToast('复制失败,请手动复制', { variant: 'error' })
|
||
}
|
||
} catch (err) {
|
||
console.error('降级复制也失败:', err)
|
||
showToast('复制失败,请手动复制', { variant: 'error' })
|
||
}
|
||
document.body.removeChild(textarea)
|
||
}
|
||
}
|
||
|
||
const handleDownloadByDirectLink = (id: string) => {
|
||
const runDownload = async () => {
|
||
if (downloadingId === id) {
|
||
return
|
||
}
|
||
|
||
setDownloadingId(id)
|
||
setDownloadProgress(10)
|
||
|
||
try {
|
||
const downloadUrl = `/api/materials/download?id=${encodeURIComponent(id)}`
|
||
await validateAndTriggerDownload(downloadUrl)
|
||
setDownloadProgress(100)
|
||
setMaterials(prev =>
|
||
prev.map(item =>
|
||
item.id === id ? { ...item, downloadCount: (item.downloadCount || 0) + 1 } : item
|
||
)
|
||
)
|
||
} catch (error) {
|
||
const { message } = parseDownloadError(error)
|
||
showToast(message, { variant: 'error' })
|
||
} finally {
|
||
setTimeout(() => {
|
||
setDownloadingId(null)
|
||
setDownloadProgress(0)
|
||
}, 300)
|
||
}
|
||
}
|
||
|
||
void runDownload()
|
||
}
|
||
|
||
const handleLike = async (id: string) => {
|
||
try {
|
||
const response = await fetch('/api/materials/like', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({ materialId: id }),
|
||
})
|
||
|
||
if (response.ok) {
|
||
// 刷新列表以更新点赞状态
|
||
fetchMaterials()
|
||
} else {
|
||
const error = await parseFetchError(response)
|
||
showToast(error.message, { variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('Like error:', error)
|
||
handleError(error, showToast)
|
||
}
|
||
}
|
||
|
||
// 收藏/取消收藏
|
||
const handleFavorite = async (id: string) => {
|
||
if (!session?.user) {
|
||
setFavoriteNotification('请先登录')
|
||
setTimeout(() => setFavoriteNotification(null), 2000)
|
||
return
|
||
}
|
||
try {
|
||
const response = await fetch('/api/materials/favorite', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ materialId: id }),
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const error = await parseFetchError(response)
|
||
setFavoriteNotification(error.message)
|
||
setTimeout(() => setFavoriteNotification(null), 2000)
|
||
return
|
||
}
|
||
|
||
const result = await response.json()
|
||
// 更新本地状态
|
||
setMaterials(prev => prev.map(m =>
|
||
m.id === id ? { ...m, isFavorited: result.isFavorited } : m
|
||
))
|
||
setFavoriteNotification(result.isFavorited ? '收藏成功' : '取消收藏成功')
|
||
setTimeout(() => setFavoriteNotification(null), 2000)
|
||
} catch (error) {
|
||
console.error('收藏操作失败:', error)
|
||
const { message } = parseApiError(error)
|
||
setFavoriteNotification(message)
|
||
setTimeout(() => setFavoriteNotification(null), 2000)
|
||
}
|
||
}
|
||
|
||
// 排序切换(点击同一字段切换正/倒序,点击不同字段重置为倒序)
|
||
const handleSortChange = (field: string) => {
|
||
if (sortBy === field) {
|
||
setSortOrder(prev => prev === 'desc' ? 'asc' : 'desc')
|
||
} else {
|
||
setSortBy(field)
|
||
setSortOrder('desc')
|
||
}
|
||
}
|
||
|
||
// 编辑素材
|
||
const handleEdit = (material: Material) => {
|
||
router.push(`/edit/${material.id}`)
|
||
}
|
||
|
||
// 素材点击处理(保存状态并跳转)
|
||
const handleMaterialClick = (material: Material) => {
|
||
// 保存滚动位置和页面状态(异步,不阻塞跳转)
|
||
try {
|
||
sessionStorage.setItem('home_scroll_position', window.scrollY.toString())
|
||
sessionStorage.setItem('home_materials_cache', JSON.stringify({
|
||
materials: materials,
|
||
displayedCount: displayedCount,
|
||
currentPage: currentPage,
|
||
selectedCategory: selectedCategory,
|
||
selectedScene: selectedScene,
|
||
selectedMaterialType: selectedMaterialType,
|
||
selectedPlatform: selectedPlatform,
|
||
selectedProductCategory: selectedProductCategory,
|
||
selectedMonth: selectedMonth,
|
||
sortBy: sortBy,
|
||
sortOrder: sortOrder,
|
||
searchKeyword: searchKeyword,
|
||
timestamp: Date.now(),
|
||
}))
|
||
} catch (e) {
|
||
console.warn('[Home] Failed to save state to sessionStorage:', e)
|
||
}
|
||
|
||
// 立即跳转,不等待任何操作(使用window.location.href确保立即跳转)
|
||
window.location.href = `/share/${material.id}`
|
||
}
|
||
|
||
// 重试转码
|
||
const handleRetryTranscode = async (materialId: string) => {
|
||
try {
|
||
const response = await fetch('/api/materials/transcode', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ materialId, forceRetry: true }),
|
||
})
|
||
if (response.ok) {
|
||
// 刷新素材列表
|
||
fetchMaterials(1, false)
|
||
} else {
|
||
const error = await parseFetchError(response)
|
||
showToast(error.message, { variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('Retry transcode error:', error)
|
||
handleError(error, showToast)
|
||
}
|
||
}
|
||
|
||
// 删除素材
|
||
const handleDelete = async (materialId: string) => {
|
||
if (!confirm('确定要删除这个素材吗?素材将移至回收站。')) return
|
||
|
||
try {
|
||
const response = await fetch(`/api/materials?id=${materialId}`, {
|
||
method: 'DELETE',
|
||
})
|
||
|
||
if (response.ok) {
|
||
// 从本地状态移除
|
||
setMaterials(prev => prev.filter(m => m.id !== materialId))
|
||
showToast('删除成功', { variant: 'success' })
|
||
} else {
|
||
const error = await parseFetchError(response)
|
||
showToast(error.message, { variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('Error deleting material:', error)
|
||
handleError(error, showToast)
|
||
}
|
||
}
|
||
|
||
useGSAP(() => {
|
||
if (status === 'loading' || typeof window.matchMedia !== 'function') return
|
||
if (!pageRef.current?.querySelector('[data-hero-title]')) return
|
||
|
||
const motion = gsap.matchMedia()
|
||
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
||
const tl = gsap.timeline({ defaults: { ease: 'expo.out' } })
|
||
tl
|
||
.fromTo(
|
||
'[data-hero-title]',
|
||
{ y: 18, autoAlpha: 0, filter: 'blur(10px)' },
|
||
{ y: 0, autoAlpha: 1, filter: 'blur(0px)', duration: 0.85, ease: 'power4.out', clearProps: 'filter,opacity,visibility,transform' },
|
||
)
|
||
.fromTo(
|
||
'[data-hero]',
|
||
{ y: 10, autoAlpha: 0 },
|
||
{ y: 0, autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility,transform' },
|
||
'-=0.6',
|
||
)
|
||
.fromTo(
|
||
'[data-toolbar]',
|
||
{ y: 12, autoAlpha: 0 },
|
||
{ y: 0, autoAlpha: 1, duration: 0.5, clearProps: 'opacity,visibility,transform' },
|
||
'-=0.35',
|
||
)
|
||
})
|
||
return () => motion.revert()
|
||
}, { scope: pageRef, dependencies: [status] })
|
||
|
||
useGSAP(() => {
|
||
if (loading || status === 'loading' || typeof window.matchMedia !== 'function') return
|
||
if (!pageRef.current?.querySelector('[data-card]')) return
|
||
|
||
const motion = gsap.matchMedia()
|
||
motion.add('(prefers-reduced-motion: no-preference)', () => {
|
||
gsap.fromTo(
|
||
'[data-card]',
|
||
{ y: 16, autoAlpha: 0 },
|
||
{
|
||
y: 0,
|
||
autoAlpha: 1,
|
||
duration: 0.45,
|
||
stagger: 0.06,
|
||
ease: 'power3.out',
|
||
clearProps: 'opacity,visibility,transform',
|
||
},
|
||
)
|
||
})
|
||
return () => motion.revert()
|
||
}, { scope: pageRef, dependencies: [loading, status] })
|
||
|
||
if (status === 'unauthenticated') {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div ref={pageRef} className="materials-archive min-h-screen bg-[#fafafa] font-display text-[#1a1c1d] antialiased">
|
||
<style>{`.materials-archive ::selection { background: #eef0fa; color: #1a1c1d; }`}</style>
|
||
{/*
|
||
THESIS: 创意资产库是白皮书的下一章——编号索引落下之后,资产以图录而不是后台表格出现。
|
||
OWN-WORLD: 白纸 #fafafa、墨字、发丝线、平面无阴影、唯一柔紫光晕、墨色 8px 主按钮。
|
||
STORY: 打开即读「01 创意资产库」,搜索是可见的输入框,上传跟在后面,类型是文字分页。
|
||
FIRST VIEWPORT: 顶栏 → 01/巨标 → 搜索+上传 → 类型索引 → 三列图录。
|
||
FORM: Stitch 桌面稿 + 看板首页下限。FINISH: unreviewed and undocumented is unfinished.
|
||
*/}
|
||
<MaterialsHeader session={session ?? null} />
|
||
|
||
{/* 收藏操作提示气泡 */}
|
||
{favoriteNotification && (
|
||
<div className="fixed top-20 right-4 z-50 px-6 py-3 bg-[#1a1c1d] text-white rounded-[8px] animate-fade-in">
|
||
{favoriteNotification}
|
||
</div>
|
||
)}
|
||
|
||
<RankingPanel />
|
||
|
||
<main className="relative mx-auto max-w-[1280px] px-6 pb-20">
|
||
<section className="relative isolate overflow-hidden pb-8 pt-16 md:pb-10 md:pt-20">
|
||
<BrandFluidField />
|
||
<div className="relative z-10">
|
||
<p className="font-en text-[11px] uppercase tracking-[0.14em] text-[#86898e]">
|
||
<span className="mr-3 tabular-nums">01</span>
|
||
Creative Assets
|
||
</p>
|
||
<h1 data-hero-title className="mt-3 font-display text-[clamp(36px,5vw,56px)] font-semibold leading-[1.08] tracking-[-0.03em]">
|
||
创意资产库
|
||
</h1>
|
||
<p data-hero className="mt-3 max-w-xl text-[14px] leading-relaxed text-[#6b7075] md:text-[15px]">
|
||
品牌中心的素材都在这里。浏览、上传、分享与下载照常可用。
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
<div data-toolbar>
|
||
<div className="mb-8 flex items-center gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<SearchBar
|
||
value={searchKeyword}
|
||
onChange={handleSearchChange}
|
||
placeholder="搜索素材 ID、SPU、任务号、店铺、场景或作者"
|
||
isSearching={isSearchPending}
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => setShowUploadModal(true)}
|
||
className={`h-12 min-w-[148px] shrink-0 rounded-[8px] bg-[#1a1c1d] px-8 text-[14px] font-medium tracking-[-0.02em] text-white transition-colors hover:bg-[#2a2d2f] ${focusRing}`}
|
||
>
|
||
上传素材
|
||
</button>
|
||
</div>
|
||
|
||
{/* 筛选面板组件 */}
|
||
<FilterPanel
|
||
selectedMaterialType={selectedMaterialType}
|
||
setSelectedMaterialType={setSelectedMaterialType}
|
||
selectedCategory={selectedCategory}
|
||
setSelectedCategory={handleCategoryChange}
|
||
selectedScene={selectedScene}
|
||
setSelectedScene={setSelectedScene}
|
||
selectedPlatform={selectedPlatform}
|
||
setSelectedPlatform={setSelectedPlatform}
|
||
selectedLivePlatform={selectedLivePlatform}
|
||
setSelectedLivePlatform={setSelectedLivePlatform}
|
||
liveDateFrom={liveDateFrom}
|
||
setLiveDateFrom={setLiveDateFrom}
|
||
liveDateTo={liveDateTo}
|
||
setLiveDateTo={setLiveDateTo}
|
||
selectedProductCategory={selectedProductCategory}
|
||
setSelectedProductCategory={setSelectedProductCategory}
|
||
selectedMonth={selectedMonth}
|
||
setSelectedMonth={setSelectedMonth}
|
||
sortBy={sortBy}
|
||
setSortBy={setSortBy}
|
||
sortOrder={sortOrder}
|
||
setSortOrder={setSortOrder}
|
||
materialTypes={materialTypesWithCounts}
|
||
categories={categories}
|
||
scenes={scenes}
|
||
platforms={platforms}
|
||
productCategories={productCategories}
|
||
months={months}
|
||
totalMaterialsCount={currentMaterialsTotalCount}
|
||
materialsCount={currentMaterialsTotalCount}
|
||
facetTotals={facetTotals}
|
||
isFilterCollapsed={isFilterCollapsed}
|
||
setIsFilterCollapsed={setIsFilterCollapsed}
|
||
themeColor={themeColor}
|
||
facetsLoading={facetsLoading || status === 'loading'}
|
||
/>
|
||
|
||
{(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth) && (
|
||
<div className="mb-8 flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-[#e7e8ea] pb-4 text-[13px] text-[#6b7075]">
|
||
<span className="text-[#86898e]">当前筛选</span>
|
||
{searchKeyword && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
搜索 {searchKeyword}
|
||
<button onClick={() => setSearchKeyword('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
{selectedMaterialType && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedMaterialType}
|
||
<button onClick={() => setSelectedMaterialType('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
{selectedCategory && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedCategory}
|
||
<button onClick={() => handleCategoryChange('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
{selectedScene && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedScene}
|
||
<button onClick={() => setSelectedScene('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
{selectedPlatform && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedPlatform}
|
||
<button onClick={() => setSelectedPlatform('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
{selectedProductCategory && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedProductCategory}
|
||
<button onClick={() => setSelectedProductCategory('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
{selectedMonth && (
|
||
<span className="inline-flex items-center gap-1.5">
|
||
{selectedMonth.length === 4
|
||
? `${selectedMonth}年`
|
||
: months.find(m => m.value === selectedMonth)?.label || selectedMonth}
|
||
<button onClick={() => setSelectedMonth('')} className={`${focusRing} rounded-sm text-[#86898e] hover:text-[#1a1c1d]`}>×</button>
|
||
</span>
|
||
)}
|
||
<button
|
||
onClick={() => {
|
||
setSearchKeyword('')
|
||
setSelectedMaterialType('')
|
||
setSelectedCategory('')
|
||
setSelectedScene('')
|
||
setSelectedPlatform('')
|
||
setSelectedProductCategory('')
|
||
setSelectedMonth('')
|
||
}}
|
||
className={`text-[#5a66b8] underline-offset-4 hover:underline ${focusRing} rounded-sm`}
|
||
>
|
||
清除所有筛选
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Materials Grid - 使用 MaterialGrid 组件 */}
|
||
<MaterialGrid
|
||
materials={materials as any}
|
||
displayedCount={displayedCount}
|
||
session={session ? { user: { id: (session.user as any)?.id || '', role: (session.user as any)?.role || '' } } : null}
|
||
loading={loading}
|
||
isTransitionLoading={isTransitionLoading}
|
||
playingAudioId={playingAudioId}
|
||
audioElement={audioElement}
|
||
audioProgress={audioProgress}
|
||
audioCurrentTime={audioCurrentTime}
|
||
audioDuration={audioDuration}
|
||
audioRef={audioRef}
|
||
downloadingId={downloadingId}
|
||
downloadProgress={downloadProgress}
|
||
themeColor={themeColor}
|
||
searchKeyword={searchKeyword}
|
||
hasFilters={!!(searchKeyword || selectedMaterialType || selectedCategory || selectedScene || selectedPlatform || selectedProductCategory || selectedMonth)}
|
||
onAudioPlay={handleAudioPlay}
|
||
onDownload={handleDownloadByDirectLink}
|
||
onLike={handleLike}
|
||
onFavorite={handleFavorite}
|
||
onShare={handleShare}
|
||
onEdit={handleEdit as any}
|
||
onDelete={handleDelete}
|
||
onMaterialClick={handleMaterialClick as any}
|
||
onRetryTranscode={handleRetryTranscode}
|
||
showToast={showToast}
|
||
/>
|
||
|
||
{/* 分页+懒加载 */}
|
||
{materials.length > 0 && !loading && (
|
||
<LoadMoreTrigger
|
||
hasMore={hasMore}
|
||
isLoading={isLoadingMore}
|
||
onLoadMore={handleLoadMore}
|
||
themeColor={themeColor}
|
||
displayedCount={displayedCount}
|
||
totalCount={materials.length}
|
||
showEndMessage={true}
|
||
/>
|
||
)}
|
||
</main>
|
||
|
||
{/* Upload Modal */}
|
||
{showUploadModal && (
|
||
<UploadModal
|
||
materialTypeCounts={materialTypeCounts}
|
||
onClose={() => setShowUploadModal(false)}
|
||
onSuccess={() => {
|
||
setShowUploadModal(false)
|
||
// 延迟刷新列表,确保数据库事务全部提交完成(修复批量上传后素材显示不全的问题)
|
||
// 延迟1秒,给数据库足够的时间完成所有事务提交
|
||
setTimeout(() => {
|
||
fetchMaterials()
|
||
}, 1000)
|
||
fetchFacets()
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Image Preview Modal */}
|
||
{previewImage && (
|
||
<div
|
||
className="fixed inset-0 z-[100] bg-black/90 flex items-center justify-center p-4"
|
||
onClick={() => setPreviewImage(null)}
|
||
>
|
||
<div className="relative max-w-7xl max-h-[90vh] w-full h-full flex items-center justify-center">
|
||
<button
|
||
onClick={() => setPreviewImage(null)}
|
||
className="absolute top-4 right-4 z-10 bg-white/10 hover:bg-white/20 text-white rounded-full p-2 transition-all"
|
||
>
|
||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||
</svg>
|
||
</button>
|
||
<div className="text-center">
|
||
<img
|
||
src={previewImage.url}
|
||
alt={previewImage.title}
|
||
className="max-w-full max-h-[80vh] object-contain mx-auto"
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
<p className="text-white mt-4 text-lg font-medium">{previewImage.title}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Share Notification */}
|
||
{showShareNotification && (
|
||
<ToastBubble message="分享链接已复制到剪贴板!" variant="success" />
|
||
)}
|
||
|
||
{/* 统一的Toast提示 */}
|
||
{toast && toast.show && (
|
||
<ToastBubble message={toast.message} variant={toast.variant} />
|
||
)}
|
||
|
||
{/* Share Modal */}
|
||
{showShareModal && shareUrl && (
|
||
<div
|
||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[200]"
|
||
onClick={() => setShowShareModal(false)}
|
||
>
|
||
<div
|
||
className="bg-white rounded-[12px] border border-[#e7e8ea] p-6 max-w-lg w-full mx-4"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="text-lg font-semibold mb-4 text-[#1a1c1d]">分享链接</h3>
|
||
<div className="bg-[#fafafa] border border-[#e7e8ea] p-3 rounded-[8px] mb-4 break-all text-sm text-[#1a1c1d]">
|
||
{shareUrl}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={copyShareUrl}
|
||
className="flex-1 bg-[#1a1c1d] text-white px-4 py-2 rounded-[8px] transition-colors hover:bg-[#2a2d2f] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||
>
|
||
复制链接
|
||
</button>
|
||
<button
|
||
onClick={() => setShowShareModal(false)}
|
||
className="flex-1 border border-[#1a1c1d] bg-white hover:bg-[#1a1c1d] hover:text-white text-[#1a1c1d] px-4 py-2 rounded-[8px] transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1a1c1d]/70 focus-visible:ring-offset-2"
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* AI聊天助手 */}
|
||
<AIChatWidget />
|
||
</div>
|
||
)
|
||
}
|