Allow multi-file live clip uploads with shared date/host/platform and per-clip titles, filter materials by live platform and date range, and fix the migration to alter the mapped materials table.
1658 lines
66 KiB
TypeScript
1658 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
|
||
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>
|
||
)
|
||
}
|