'use client' import { useCallback, useEffect, useMemo, useState, useRef, useLayoutEffect } from 'react' import { useSession } from 'next-auth/react' import { useRouter, useSearchParams } from 'next/navigation' import Image from 'next/image' import { format } from 'date-fns' import UploadModal from '@/components/UploadModal' import Header from '@/components/Header' import ChangePasswordModal from '@/components/ChangePasswordModal' import MaterialPreviewModal from '@/components/MaterialPreviewModal' import Modal from '@/components/ui/Modal' import Button from '@/components/ui/Button' import Tag from '@/components/ui/Tag' import Avatar from '@/components/ui/Avatar' import MaterialActionButtons from '@/components/MaterialActionButtons' import EditMaterialModal from '@/components/EditMaterialModal' import { useThemeStyles, cn } from '@/lib/ui-styles' import { useDialog } from '@/components/ui/DialogProvider' import { BUILTIN_TEMPLATE_NAMES as BUILTIN_TEMPLATE_NAMES_MAP, BUILTIN_TEMPLATE_TYPES as BUILTIN_TEMPLATE_TYPES_MAP, } from '@/lib/upload-template-presets' const TAG_COLOR_PALETTE = ['blue', 'green', 'orange', 'purple', 'indigo', 'pink', 'cyan'] as const type TagColorToken = (typeof TAG_COLOR_PALETTE)[number] const FALLBACK_MATERIAL_TYPES = Array.from(new Set(Object.values(BUILTIN_TEMPLATE_TYPES_MAP))) const MONTH_KEYS = ['01', '02', '03', '04', '05', '06', '07', '08', '09', '10', '11', '12'] as const const VIDEO_STATUS_META: Record<'pending' | 'processing' | 'done' | 'failed', { label: string; className: string }> = { pending: { label: '等待转码', className: 'bg-amber-500/90' }, processing: { label: '转码中', className: 'bg-sky-500/90' }, done: { label: '可预览', className: 'bg-emerald-500/90' }, failed: { label: '转码失败', className: 'bg-rose-500/90' }, } const normalizeMaterialType = (value: string) => (typeof value === 'string' ? value.trim() : '') const normalizeMaterialTypeList = (list: string[]) => { // 保留所有值,包括自定义模板(template:...格式) const normalized = list.map(normalizeMaterialType).filter(Boolean) // 使用 Set 去重,但保留所有值(包括自定义模板) return Array.from(new Set(normalized)) } interface Material { id: string userId: string previewUrl: string videoCoverUrl?: string | null videoPreview540Url?: string | null videoPreviewClipUrl?: string | null videoProcessingStatus?: string | null videoDuration?: number | null materialKind?: 'IMAGE' | 'VIDEO' | 'AUDIO' | 'OTHER' displayImages?: string[] productModel: string brandName?: string | null seriesName?: string | null skuCode?: string | null materialTexture?: string | null categoryName: string sceneName: string materialType: string renderer: string renderTime?: string | null imageResolution?: string | null colorMode?: string | null authorName: string zipFileName: string zipFileSize: string | number downloadCount: number likeCount: number isLiked: boolean createdAt: string isPremium: boolean templateId?: string | null template?: { name: string } | null user: { username: string avatar?: string | null } rankingBadges?: RankingBadge[] // 音频相关字段 audioDuration?: string | null audioCategory?: string | null audioScene?: string | null audioUrl?: string | null } // 内置预制模板名称(用于区分“自定义模板”与“预制模板”) const BUILTIN_TEMPLATE_NAMES = new Set([ ...Object.values(BUILTIN_TEMPLATE_NAMES_MAP), ]) // 计算素材卡片上用于区分模板的标签文案: // - 如果绑定了“自定义模板”,优先使用模板名称(去掉尾部的“素材模板”) // - 如果是预制模板或没有模板信息,继续使用 materialType(保持旧行为) const getMaterialTemplateLabel = (material: Material): string => { const templateName = material.template?.name?.trim() if (templateName) { // 去掉尾部的“素材模板”后缀 const stripped = templateName.replace(/\s*素材模板\s*$/g, '').trim() const isBuiltin = BUILTIN_TEMPLATE_NAMES.has(templateName) || BUILTIN_TEMPLATE_NAMES.has(stripped + '素材模板') || Object.values(BUILTIN_TEMPLATE_TYPES_MAP).includes(stripped) // 非预制模板:认为是“自定义模板”,展示模板名称 if (!isBuiltin) { return stripped || templateName } } // 预制模板或没有模板信息时,使用 materialType return material.materialType } type RankingBadge = { label: string } type TemplateFieldMeta = { key: string label: string type: string visibleOnHomeFilter?: boolean optionsStatic?: string | null } type HomeFilterOption = { label: string value: string count?: number } type HomeFilterFieldConfig = { key: string label: string type?: string options: HomeFilterOption[] /** 该字段有非空值的素材总数,用于"全部"按钮显示 */ totalCount?: number } type ShareTokenItem = { id: string materialId: string createdByUserId: string createdByName: string createdAt: string expiresAt: string revokedAt: string | null status: 'active' | 'expired' | 'revoked' } export default function Home() { const { data: session, status } = useSession() const router = useRouter() const searchParams = useSearchParams() const styles = useThemeStyles() const { alert, confirm } = useDialog() const currentUserId = (session?.user as any)?.id || '' const [materials, setMaterials] = useState([]) const [loading, setLoading] = useState(true) const [showUploadModal, setShowUploadModal] = useState(false) const [searchKeyword, setSearchKeyword] = useState('') const [searchQuery, setSearchQuery] = useState('') const [selectedCategory, setSelectedCategory] = useState('') const [selectedScene, setSelectedScene] = useState('') const [selectedMaterialType, setSelectedMaterialType] = useState('') // 素材类型筛选(内置模板)或模板ID(自定义模板) const [selectedTemplateId, setSelectedTemplateId] = useState('') // 自定义模板ID筛选 const [selectedPlatform, setSelectedPlatform] = useState('') // 平台分类筛选 const [materialTypes, setMaterialTypes] = useState([]) const [materialTypesCount, setMaterialTypesCount] = useState>({}) const [homeFilterFields, setHomeFilterFields] = useState>({}) const [homeFilterSelections, setHomeFilterSelections] = useState>({}) const [homeFilterReady, setHomeFilterReady] = useState(false) const [downloadingIds, setDownloadingIds] = useState>(new Set()) const [downloadProgresses, setDownloadProgresses] = useState>({}) const [selectedYear, setSelectedYear] = useState('') const [selectedMonth, setSelectedMonth] = useState('') const [months, setMonths] = useState>([]) const [sortBy, setSortBy] = useState<'createdAt' | 'downloadCount' | 'likeCount'>('createdAt') const [previewMaterial, setPreviewMaterial] = useState(null) const [totalMaterialsCount, setTotalMaterialsCount] = useState(0) const [currentPage, setCurrentPage] = useState(1) const [shareUrl, setShareUrl] = useState(null) const [shareMaterialId, setShareMaterialId] = useState(null) const [shareTokens, setShareTokens] = useState([]) const [shareTokensLoading, setShareTokensLoading] = useState(false) const [shareTokensError, setShareTokensError] = useState(null) const [showShareModal, setShowShareModal] = useState(false) const [showShareNotification, setShowShareNotification] = useState(false) const searchDebounceRef = useRef(null) const [isGridTransitioning, setIsGridTransitioning] = useState(false) const videoRefs = useRef>({}) const [playingVideoIds, setPlayingVideoIds] = useState>(new Set()) // 音频播放状态 const audioRef = useRef(null) const [playingAudioId, setPlayingAudioId] = useState(null) const [audioProgress, setAudioProgress] = useState(0) // 0-100 const chipListRef = useRef(null) const copyTextToClipboard = async (text: string) => { if (!text) return false try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text) return true } } catch (error) { console.warn('[Share] Primary clipboard copy failed:', error) } try { const textarea = document.createElement('textarea') textarea.value = text textarea.style.position = 'fixed' textarea.style.opacity = '0' document.body.appendChild(textarea) textarea.focus() textarea.select() const successful = document.execCommand('copy') document.body.removeChild(textarea) return successful } catch (error) { console.error('[Share] Fallback clipboard copy failed:', error) return false } } const [themeColor, setThemeColor] = useState('#1f2937') // 主题颜色 const [homeDeleteButtonAdminOnly, setHomeDeleteButtonAdminOnly] = useState(true) // 首页删除按钮是否仅管理员可见 const [showIntranetTip, setShowIntranetTip] = useState(false) // 内网切换提示 const [showFilterModal, setShowFilterModal] = useState(false) // 筛选弹窗 const [showChangePasswordModal, setShowChangePasswordModal] = useState(false) // 修改密码弹窗 const [isDefaultPassword, setIsDefaultPassword] = useState(false) // 是否使用默认密码 const [initialPasswordCheckDone, setInitialPasswordCheckDone] = useState(false) // 初始密码检查是否完成 const [isScrolled, setIsScrolled] = useState(false) // 是否已滚动 const searchSectionRef = useRef(null) // 搜索区域引用(包含TAG和搜索框) const searchStartScrollStateRef = useRef(false) // 记录搜索开始时的滚动状态 const [materialRankings, setMaterialRankings] = useState<{ downloads: string[] likes: string[] premium: string[] }>({ downloads: [], likes: [], premium: [], }) const INITIAL_BATCH_COUNT = 24 const [displayedCount, setDisplayedCount] = useState(INITIAL_BATCH_COUNT) // 初始显示数量 const [isLoadingMore, setIsLoadingMore] = useState(false) const [hasMoreMaterials, setHasMoreMaterials] = useState(true) const loadMoreRef = useRef(null) const selectedYearMonth = selectedYear && selectedMonth ? `${selectedYear}-${selectedMonth}` : '' const homeFilterCount = Object.keys(homeFilterSelections).length const filterIndicatorCount = [selectedCategory, selectedScene, selectedPlatform, selectedYear, selectedYearMonth].filter(Boolean).length + homeFilterCount const hasActiveFilters = Boolean(searchQuery) || Boolean(selectedCategory) || Boolean(selectedScene) || Boolean(selectedPlatform) || Boolean(selectedYear) || Boolean(selectedYearMonth) || homeFilterCount > 0 const pageSize = 20 const categoryField = homeFilterFields.categoryName const sceneField = homeFilterFields.sceneName const platformField = homeFilterFields.platformName const showCategoryFilter = homeFilterReady ? Boolean(categoryField) : true const showSceneFilter = homeFilterReady ? Boolean(sceneField) : true const showPlatformFilter = homeFilterReady ? Boolean(platformField) : false const categoryLabel = categoryField?.label || '使用店铺' const sceneLabel = sceneField?.label || '场景类别' const platformLabel = platformField?.label || '平台分类' const categoryOptions = useMemo(() => categoryField?.options ?? [], [categoryField]) const sceneOptions = useMemo(() => sceneField?.options ?? [], [sceneField]) const platformOptions = useMemo(() => platformField?.options ?? [], [platformField]) const dynamicHomeFilterEntries = useMemo( () => { const baseKeys = new Set(['categoryName', 'sceneName', 'platformName']) return Object.entries(homeFilterFields).filter( ([key, field]) => !baseKeys.has(key) && (field.options?.length ?? 0) > 0 ) }, [homeFilterFields] ) const orderedHomeFilterKeys = useMemo(() => Object.keys(homeFilterFields), [homeFilterFields]) const yearMonthStats = useMemo(() => { const map: Record }> = {} months.forEach((item) => { const [year, monthValue] = item.value.split('-') if (!year || !monthValue) return if (!map[year]) { map[year] = { count: 0, months: {} } } const numericCount = item.count ?? 0 map[year].count += numericCount map[year].months[monthValue] = numericCount }) return map }, [months]) const yearOptions = useMemo( () => Object.entries(yearMonthStats) .map(([year, data]) => ({ year, count: data.count })) .filter(({ count }) => count > 0) .sort((a, b) => b.year.localeCompare(a.year)), [yearMonthStats] ) const availableMonths = useMemo(() => { if (!selectedYear) return [] const monthCounts = yearMonthStats[selectedYear]?.months ?? {} return MONTH_KEYS.filter((key) => (monthCounts[key] ?? 0) > 0) }, [selectedYear, yearMonthStats]) const monthCountsForSelectedYear = selectedYear ? yearMonthStats[selectedYear]?.months ?? {} : {} const selectedMonthLabel = useMemo(() => { if (!selectedYearMonth) return '' const match = months.find((item) => item.value === selectedYearMonth) if (match) return match.label if (selectedYear && selectedMonth) { return `${selectedYear}年${parseInt(selectedMonth, 10)}月` } return selectedYearMonth }, [months, selectedYearMonth, selectedYear, selectedMonth]) const filterCardClass = 'space-y-1.5 pb-3 border-b border-slate-200 dark:border-slate-700 last:border-b-0 last:pb-0' const filterCardTitleClass = 'text-sm font-semibold text-slate-900 dark:text-white' const buildFilterPillClass = (isActive: boolean) => cn( 'px-3 py-1.5 rounded-md text-xs font-medium transition-colors border flex items-center gap-1.5 whitespace-nowrap', isActive ? 'text-white' : 'bg-white dark:bg-slate-900 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-400 dark:hover:border-slate-500' ) const getFilterPillStyle = (isActive: boolean) => isActive ? { backgroundColor: themeColor, borderColor: themeColor } : undefined const applyMaterialTypeList = useCallback((rawList?: string[]) => { console.log('[Home] applyMaterialTypeList called with:', rawList) const normalizedList = rawList && rawList.length > 0 ? normalizeMaterialTypeList(rawList) : [] console.log('[Home] Normalized list:', normalizedList) const finalList = normalizedList.length > 0 ? normalizedList : FALLBACK_MATERIAL_TYPES console.log('[Home] Final material types list to set:', finalList) setMaterialTypes(finalList) // 如果当前选中的分类不在新的列表中,自动清空以避免"选中却无按钮" setSelectedMaterialType((prev) => { if (prev && !finalList.includes(prev)) { return '' } return prev }) // 检查自定义模板是否还在列表中 setSelectedTemplateId((prev) => { if (!prev) return prev const templateStillExists = finalList.some((t) => t.startsWith(`template:${prev}:`)) return templateStillExists ? prev : '' }) }, []) const handleMaterialTypeSelection = useCallback( (rawValue: string) => { // 如果是自定义模板(格式:template:模板ID:模板名称) if (rawValue.startsWith('template:')) { const parts = rawValue.split(':') const templateId = parts[1] const templateName = parts.slice(2).join(':') // 支持名称中包含冒号的情况 setSelectedTemplateId((prev) => (prev === templateId ? '' : templateId)) setSelectedMaterialType('') // 清空内置模板选择 } else { // 内置模板 const normalizedValue = normalizeMaterialType(rawValue) if (!normalizedValue) { setSelectedMaterialType('') setSelectedTemplateId('') // 清空自定义模板选择 return } setSelectedMaterialType((prev) => (prev === normalizedValue ? '' : normalizedValue)) setSelectedTemplateId('') // 清空自定义模板选择 } }, [] ) const getRankingBadges = useCallback( (materialId: string, overrides?: { isPremium?: boolean }): RankingBadge[] => { const badges: RankingBadge[] = [] const premiumIncluded = typeof overrides?.isPremium === 'boolean' ? overrides.isPremium : materialRankings.premium.includes(materialId) const downloadIndex = materialRankings.downloads.indexOf(materialId) const likeIndex = materialRankings.likes.indexOf(materialId) if (premiumIncluded) { badges.push({ label: '精品' }) } if (downloadIndex > -1 && downloadIndex < 10) { badges.push({ label: '下载榜' }) } if (likeIndex > -1 && likeIndex < 10) { badges.push({ label: '点赞榜' }) } return badges }, [materialRankings] ) useEffect(() => { if (status === 'unauthenticated') { router.push('/login') } }, [status, router]) // 初始管理员强制改密检查 useEffect(() => { const checkInitialAdminPassword = async () => { if (status !== 'authenticated') { setInitialPasswordCheckDone(false) return } try { const passwordCheck = await fetch('/api/user/check-default-password') const passwordData = await passwordCheck.json() if (passwordData.isDefaultPassword) { console.log('检测到系统初始化账号仍需首次改密,强制修改密码') setIsDefaultPassword(true) setShowChangePasswordModal(true) } else { setIsDefaultPassword(false) setShowChangePasswordModal(false) } } catch (error) { console.error('初始管理员改密检查失败:', error) } finally { setInitialPasswordCheckDone(true) } } checkInitialAdminPassword() }, [status, session?.user?.email]) // 检查是否有未完成的上传(断点续传) useEffect(() => { if (status !== 'authenticated') return const checkUnfinishedUpload = () => { try { const savedState = localStorage.getItem('uploadState') if (savedState) { const data = JSON.parse(savedState) // 检查是否在24小时内(避免过期数据) const oneDayAgo = Date.now() - 24 * 60 * 60 * 1000 // 只有在有实际文件且时间戳有效时才打开上传面板 if (data.timestamp && data.timestamp > oneDayAgo && data.files && data.files.length > 0) { // 如果有未完成的上传,自动打开上传模态框 // UploadModal 组件内部会检查并显示恢复对话框 setShowUploadModal(true) } else { // 超过24小时或没有有效文件,清除过期数据 localStorage.removeItem('uploadState') } } } catch (e) { console.error('Failed to check unfinished upload:', e) localStorage.removeItem('uploadState') } } checkUnfinishedUpload() }, [status]) // 检查是否需要显示内网切换提示(使用后端判断) useEffect(() => { const checkNetworkStatus = async () => { if (status !== 'authenticated') return const tipDismissed = sessionStorage.getItem('intranet_tip_dismissed') if (tipDismissed === 'true') return try { // 调用后端API获取准确的网络状态 const response = await fetch('/api/network-status') const data = await response.json() if (data.success) { console.log(`[Network Status] isIntranet: ${data.isIntranet}, isPublicDomain: ${data.isPublicDomain}`) // 如果用户在内网但访问的是公网域名,显示切换提示 if (data.isIntranet && data.isPublicDomain) { console.log('💡 后端检测到用户在内网访问公网,显示切换提示') setShowIntranetTip(true) } } } catch (error) { console.error('网络状态检测失败:', error) } } checkNetworkStatus() }, [status]) const handleDismissIntranetTip = () => { sessionStorage.setItem('intranet_tip_dismissed', 'true') setShowIntranetTip(false) } const handleSwitchToIntranet = () => { const url = process.env.NEXT_PUBLIC_INTRANET_URL if (url) { window.location.href = url } else { alert({ title: '提示', message: '未配置内网地址,请联系管理员设置 NEXT_PUBLIC_INTRANET_URL', variant: 'warning', }) } } // 获取主题颜色和系统级开关 const fetchThemeColor = useCallback(async () => { try { const response = await fetch('/api/system-settings') if (response.ok) { const data = await response.json() setThemeColor(data.headerBgColor || '#1f2937') // 首页删除按钮模式:默认仅管理员可见;当后端配置为 ALL 时,所有人都能看到按钮 setHomeDeleteButtonAdminOnly((data.homeDeleteButtonMode ?? 'ADMIN_ONLY') !== 'ALL') } } catch (error) { console.error('获取主题颜色失败:', error) } }, []) // 当搜索关键词变化时,记录搜索开始时的滚动状态 useEffect(() => { if (searchKeyword) { // 记录搜索开始时的滚动状态 searchStartScrollStateRef.current = isScrolled } else { // 搜索清空时,重置记录 searchStartScrollStateRef.current = false } }, [searchKeyword, isScrolled]) // 滚动监听,检测搜索区域是否可见 useEffect(() => { const handleScroll = () => { if (!searchSectionRef.current) return // 如果有搜索关键词,保持搜索开始时的滚动状态,不要因为滚动而改变 // 这样搜索时搜索栏不会自动展开或收缩 if (searchKeyword) { setIsScrolled(searchStartScrollStateRef.current) return } const rect = searchSectionRef.current.getBoundingClientRect() // 当搜索区域的底部滚动到窗口顶部以上时,显示收缩的顶部栏 // 使用一个小的阈值(100px)来提前触发,让过渡更自然 const threshold = 100 const isScrolledPast = rect.bottom < threshold setIsScrolled(isScrolledPast) } window.addEventListener('scroll', handleScroll, { passive: true }) handleScroll() // 初始检查 return () => window.removeEventListener('scroll', handleScroll) }, [materialTypes.length, searchKeyword]) // 获取素材排行榜数据 const fetchMaterialRankings = useCallback(async () => { try { const response = await fetch('/api/material-rankings') if (response.ok) { const data = await response.json() setMaterialRankings({ downloads: Array.isArray(data?.downloads) ? data.downloads.slice(0, 10) : [], likes: Array.isArray(data?.likes) ? data.likes.slice(0, 10) : [], premium: Array.isArray(data?.premium) ? data.premium.slice(0, 10) : [], }) } } catch (error) { console.error('Error fetching material rankings:', error) } }, []) const fetchMaterialTypesCount = useCallback(async (types: string[]) => { try { const counts: Record = {} await Promise.all( types.map(async (type) => { // 如果是自定义模板(格式:template:模板ID:模板名称),使用 templateId 参数 if (type.startsWith('template:')) { const parts = type.split(':') const templateId = parts[1] // 提取模板ID const response = await fetch(`/api/materials?templateId=${encodeURIComponent(templateId)}&pageSize=1`) if (response.ok) { const data = await response.json() counts[type] = data.totalCount || 0 } else { counts[type] = 0 } } else { // 内置模板使用 materialType 参数 const response = await fetch(`/api/materials?materialType=${encodeURIComponent(type)}&pageSize=1`) if (response.ok) { const data = await response.json() counts[type] = data.totalCount || 0 } else { counts[type] = 0 } } }) ) setMaterialTypesCount(counts) } catch (error) { console.error('Error fetching material types count:', error) } }, []) const fetchMaterialTypes = useCallback(async () => { try { const response = await fetch('/api/material-types', { cache: 'no-store', }) if (response.ok) { const data = await response.json() console.log('[Home] Fetched material types from API:', data) console.log('[Home] Data type:', typeof data, 'Is array:', Array.isArray(data), 'Length:', Array.isArray(data) ? data.length : 'N/A') if (Array.isArray(data) && data.length > 0) { console.log('[Home] Applying material type list:', data) console.log('[Home] Custom types in list:', data.filter(t => t.startsWith('template:'))) applyMaterialTypeList(data) // 获取每个类型的素材数量 fetchMaterialTypesCount(data) return } if (Array.isArray(data?.types) && data.types.length > 0) { console.log('[Home] Applying material type list (nested):', data.types) applyMaterialTypeList(data.types) fetchMaterialTypesCount(data.types) return } } // 接口返回异常或为空时使用兜底 console.warn('[Home] Material types API returned empty or invalid data, using fallback') applyMaterialTypeList(FALLBACK_MATERIAL_TYPES) fetchMaterialTypesCount(FALLBACK_MATERIAL_TYPES) } catch (error) { console.error('[Home] Error fetching material types:', error) applyMaterialTypeList(FALLBACK_MATERIAL_TYPES) fetchMaterialTypesCount(FALLBACK_MATERIAL_TYPES) } }, [applyMaterialTypeList, fetchMaterialTypesCount]) const fetchHomeFilterFields = useCallback(async () => { try { const response = await fetch('/api/home-filter-fields', { cache: 'no-store', }) if (!response.ok) { throw new Error('Fetch failed') } const data = await response.json() const map: Record = {} if (Array.isArray(data?.fields)) { data.fields.forEach((field: HomeFilterFieldConfig) => { if (!field?.key) return // 解析totalCount(该字段有非空值的素材总数) const fieldTotalCount = typeof field.totalCount === 'number' ? field.totalCount : undefined map[field.key] = { key: field.key, label: field.label || field.key, type: field.type, totalCount: fieldTotalCount, options: Array.isArray(field.options) ? field.options .map((option: any) => { if (!option) return null if (typeof option === 'string') { return { label: option, value: option } } if (typeof option.value === 'string') { const count = typeof option.count === 'number' ? option.count : typeof option.count === 'string' ? Number(option.count) : undefined return { label: option.label || option.value, value: option.value, count: Number.isFinite(count) ? count : undefined, } } return null }) .filter((option): option is HomeFilterOption => Boolean(option)) : [], } }) } setHomeFilterFields(map) } catch (error) { console.error('Error fetching home filter fields:', error) setHomeFilterFields({}) } finally { setHomeFilterReady(true) } }, []) const fetchMonthsAndTotal = useCallback(async () => { try { const response = await fetch('/api/materials/stats') if (response.ok) { const data = await response.json() setMonths(data.months || []) setTotalMaterialsCount(data.total || 0) } } catch (error) { console.error('Error fetching months:', error) } }, []) const fetchMaterials = useCallback( async (page = 1, append = false) => { try { if (!append) { // 首次加载时设置loading状态 setLoading(true) setHasMoreMaterials(true) } const params = new URLSearchParams() if (searchQuery) params.append('search', searchQuery) if (selectedCategory) params.append('category', selectedCategory) if (selectedScene) params.append('scene', selectedScene) if (selectedMaterialType) params.append('materialType', selectedMaterialType) if (selectedTemplateId) params.append('templateId', selectedTemplateId) if (selectedPlatform) params.append('platform', selectedPlatform) if (selectedYearMonth) { params.append('month', selectedYearMonth) } else if (selectedYear) { params.append('year', selectedYear) } Object.entries(homeFilterSelections).forEach(([key, value]) => { if (value) { params.append(`homeField_${key}`, value) } }) if (sortBy) params.append('sortBy', sortBy) params.append('page', page.toString()) params.append('pageSize', pageSize.toString()) const url = `/api/materials${params.toString() ? '?' + params.toString() : ''}` console.log('[Home] Fetching materials from:', url) const response = await fetch(url) if (response.ok) { const data = await response.json() const materialsList = (Array.isArray(data.materials) ? data.materials : Array.isArray(data) ? data : []) as Material[] console.log('[Home] Received materials:', materialsList.length, 'items') let appendedItems: Material[] = materialsList if (append) { setMaterials((prev) => { const existingIds = new Set(prev.map((item) => item.id)) const deduped = materialsList.filter((item) => !existingIds.has(item.id)) appendedItems = deduped if (deduped.length === 0) return prev return [...prev, ...deduped] }) } else { setMaterials(materialsList) setDisplayedCount(Math.min(INITIAL_BATCH_COUNT, materialsList.length)) } const appendedCount = append ? appendedItems.length : materialsList.length setCurrentPage(page) const nextTotalPages = data.totalPages || 1 setHasMoreMaterials(appendedCount > 0 && page < nextTotalPages) if (typeof data.totalCount === 'number') { setTotalMaterialsCount(data.totalCount) } else if (!append) { setTotalMaterialsCount(materialsList.length) } // 获取月份统计和总数 fetchMonthsAndTotal() return appendedCount } else { console.error('[Home] Failed to fetch materials:', response.status, response.statusText) } } catch (error) { console.error('Error fetching materials:', error) if (!append) { setMaterials([]) setDisplayedCount(0) } } finally { if (!append) { // 清除loading状态 setLoading(false) } } return 0 }, [ fetchMonthsAndTotal, homeFilterSelections, pageSize, searchQuery, selectedCategory, selectedScene, selectedMaterialType, selectedTemplateId, selectedPlatform, selectedYear, selectedYearMonth, sortBy, ] ) const handleLoadMore = useCallback(async () => { if (isLoadingMore) return if (displayedCount < materials.length) { setIsLoadingMore(true) setDisplayedCount((prev) => Math.min(prev + INITIAL_BATCH_COUNT, materials.length)) setIsLoadingMore(false) return } if (!hasMoreMaterials) return setIsLoadingMore(true) const appended = await fetchMaterials(currentPage + 1, true) if (appended > 0) { setDisplayedCount((prev) => Math.min(prev + INITIAL_BATCH_COUNT, materials.length + appended)) } setIsLoadingMore(false) }, [displayedCount, materials.length, hasMoreMaterials, fetchMaterials, currentPage, isLoadingMore]) // IntersectionObserver实现自动懒加载 useEffect(() => { if (!loadMoreRef.current) return const observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { handleLoadMore() } }, { threshold: 0.2 } ) observer.observe(loadMoreRef.current) return () => { observer.disconnect() } }, [handleLoadMore, displayedCount, materials.length, hasMoreMaterials]) useEffect(() => { fetchMaterials() fetchMaterialTypes() fetchHomeFilterFields() fetchThemeColor() fetchMaterialRankings() }, [fetchMaterials, fetchMaterialTypes, fetchHomeFilterFields, fetchThemeColor, fetchMaterialRankings]) // 监听后台模板改动事件,自动刷新筛选字段和素材类型 useEffect(() => { const handleStorageChange = (e: StorageEvent) => { if (e.key === 'templates-updated') { fetchMaterialTypes() fetchHomeFilterFields() } } const handleMessage = (e: MessageEvent) => { if (e.data?.type === 'templates-updated') { fetchMaterialTypes() fetchHomeFilterFields() } } // 监听 localStorage 变化(跨标签页) window.addEventListener('storage', handleStorageChange) // 监听 BroadcastChannel 消息(同源跨标签页) const channel = new BroadcastChannel('templates-channel') channel.addEventListener('message', handleMessage) return () => { window.removeEventListener('storage', handleStorageChange) channel.removeEventListener('message', handleMessage) channel.close() } }, [fetchMaterialTypes, fetchHomeFilterFields]) useEffect(() => { if (selectedCategory && !categoryOptions.some((option) => option.value === selectedCategory)) { setSelectedCategory('') } }, [categoryOptions, selectedCategory]) useEffect(() => { if (selectedScene && !sceneOptions.some((option) => option.value === selectedScene)) { setSelectedScene('') } }, [sceneOptions, selectedScene]) useEffect(() => { if (selectedPlatform && !platformOptions.some((option) => option.value === selectedPlatform)) { setSelectedPlatform('') } }, [platformOptions, selectedPlatform]) useEffect(() => { if (status === 'authenticated') { fetchMaterials() } }, [status, fetchMaterials]) useEffect(() => { const validIds = new Set(materials.map((item) => item.id)) Object.keys(videoRefs.current).forEach((id) => { if (!validIds.has(id)) { delete videoRefs.current[id] } }) }, [materials]) // 格式化文件大小 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 formatVideoDuration = (seconds?: number | null): string => { if (!seconds || Number.isNaN(seconds) || seconds <= 0) return '' const mins = Math.floor(seconds / 60) const secs = Math.floor(seconds % 60) return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}` } const handleCardVideoHover = useCallback((materialId: string, shouldPlay: boolean) => { const videoEl = videoRefs.current[materialId] if (!videoEl) return if (shouldPlay) { // 更新播放状态 setPlayingVideoIds(prev => new Set(prev).add(materialId)) try { // 先暂停并重置,避免之前的播放请求干扰 videoEl.pause() videoEl.currentTime = 0 // 使用 requestAnimationFrame 确保 DOM 更新后再播放 requestAnimationFrame(() => { const playPromise = videoEl.play() if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch((error) => { // 忽略 AbortError,这是正常的(当快速移入移出时) if (error.name !== 'AbortError') { console.warn('[Home] Video preview play failed:', error) } }) } }) } catch (error) { // 忽略 AbortError if (error instanceof Error && error.name !== 'AbortError') { console.warn('[Home] Video preview play exception:', error) } } } else { // 更新播放状态 setPlayingVideoIds(prev => { const next = new Set(prev) next.delete(materialId) return next }) // 暂停时也使用 requestAnimationFrame,避免与播放请求冲突 requestAnimationFrame(() => { try { videoEl.pause() videoEl.currentTime = 0 } catch (error) { // 忽略暂停时的错误 } }) } }, []) // 音频播放控制 const handleAudioPlay = useCallback((materialId: string, audioUrl: string) => { // 如果点击的是当前正在播放的音频,则暂停 if (playingAudioId === materialId) { if (audioRef.current) { audioRef.current.pause() } setPlayingAudioId(null) setAudioProgress(0) return } // 停止之前的音频 if (audioRef.current) { audioRef.current.pause() } // 创建新的音频元素 const audio = new Audio(audioUrl) audioRef.current = audio // 监听播放进度 audio.addEventListener('timeupdate', () => { if (audio.duration) { setAudioProgress((audio.currentTime / audio.duration) * 100) } }) // 监听播放结束 audio.addEventListener('ended', () => { setPlayingAudioId(null) setAudioProgress(0) }) // 监听错误 audio.addEventListener('error', (e) => { console.error('[Home] Audio play error:', e) setPlayingAudioId(null) setAudioProgress(0) }) // 开始播放 audio.play().then(() => { setPlayingAudioId(materialId) }).catch((error) => { console.error('[Home] Audio play failed:', error) setPlayingAudioId(null) }) }, [playingAudioId]) // 组件卸载时清理音频 useEffect(() => { return () => { if (audioRef.current) { audioRef.current.pause() audioRef.current = null } } }, []) const resolveVideoStatus = (status?: string | null): 'pending' | 'processing' | 'done' | 'failed' => { const normalized = (status || '').trim().toLowerCase() if (normalized === 'processing' || normalized === 'done' || normalized === 'failed' || normalized === 'pending') { return normalized } return 'pending' } const formatShareDateTime = (value?: string | null) => { if (!value) return '-' const date = new Date(value) if (Number.isNaN(date.getTime())) return '-' return format(date, 'yyyy-MM-dd HH:mm') } const getShareTokenStatusMeta = (status: ShareTokenItem['status']) => { if (status === 'active') { return { label: '有效', className: 'border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/20 dark:text-emerald-300', } } if (status === 'expired') { return { label: '已过期', className: 'border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-800 dark:bg-amber-900/20 dark:text-amber-300', } } return { label: '已撤销', className: 'border-slate-200 bg-slate-100 text-slate-500 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-400', } } const fetchShareTokens = useCallback(async (materialId: string) => { setShareTokensLoading(true) setShareTokensError(null) try { const response = await fetch(`/api/materials/share/tokens?materialId=${encodeURIComponent(materialId)}`, { cache: 'no-store', }) const data = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(data?.error || '获取分享链接失败') } setShareTokens(Array.isArray(data?.tokens) ? data.tokens : []) } catch (error) { console.error('[Share] Fetch share tokens failed:', error) setShareTokens([]) setShareTokensError(error instanceof Error ? error.message : '获取分享链接失败') } finally { setShareTokensLoading(false) } }, []) // 分享功能 const handleShare = async (materialId: string) => { try { const response = await fetch('/api/materials/share', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId }), }) const data = await response.json().catch(() => ({})) if (!response.ok || !data?.shareToken) { throw new Error(data?.error || '创建分享链接失败') } const base = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin const url = `${base}/share/${materialId}?token=${encodeURIComponent(data.shareToken)}` setShareUrl(url) setShareMaterialId(materialId) setShowShareModal(true) void fetchShareTokens(materialId) const copied = await copyTextToClipboard(url) if (copied) { setShowShareNotification(true) setTimeout(() => setShowShareNotification(false), 2000) } } catch (error) { console.error('[Share] Create share token failed:', error) alert({ title: '分享失败', message: error instanceof Error ? error.message : '创建分享链接失败,请稍后重试', variant: 'error', }) } } // 实时搜索:输入时更新 searchQuery,但不做整页刷新,只重新请求列表数据 useEffect(() => { if (searchDebounceRef.current) { window.clearTimeout(searchDebounceRef.current) } searchDebounceRef.current = window.setTimeout(() => { setSearchQuery(searchKeyword.trim()) }, 400) return () => { if (searchDebounceRef.current) { window.clearTimeout(searchDebounceRef.current) } } }, [searchKeyword]) const copyShareUrl = async () => { if (shareUrl) { const copied = await copyTextToClipboard(shareUrl) if (copied) { alert({ title: '复制成功', message: '链接已复制,可直接分享给同事', variant: 'success', }) return } alert({ title: '复制失败', message: '浏览器未授予复制权限,请手动复制', variant: 'error', }) } } const handleRevokeShareToken = async (tokenId: string) => { const confirmed = await confirm({ title: '撤销分享链接', message: '撤销后,持有该链接的人将不能继续访问这个素材。确定要撤销吗?', confirmText: '撤销', cancelText: '取消', variant: 'danger', }) if (!confirmed) return try { const response = await fetch(`/api/materials/share/tokens/${encodeURIComponent(tokenId)}`, { method: 'DELETE', }) const data = await response.json().catch(() => ({})) if (!response.ok) { throw new Error(data?.error || '撤销分享链接失败') } if (shareMaterialId) { await fetchShareTokens(shareMaterialId) } alert({ title: '已撤销', message: '该分享链接已失效', variant: 'success', }) } catch (error) { console.error('[Share] Revoke share token failed:', error) alert({ title: '撤销失败', message: error instanceof Error ? error.message : '撤销分享链接失败,请稍后重试', variant: 'error', }) } } // TAG 按钮区域不再做宽度动画,改为简单左对齐,避免任何因测量/过渡导致的整体闪动 const renderMaterialTypeChips = () => { const typeList = materialTypes.length > 0 ? materialTypes : FALLBACK_MATERIAL_TYPES if (!typeList.length) return null // 按素材数量降序排序 const sortedTypeList = [...typeList].sort((a, b) => { const countA = materialTypesCount[a] ?? 0 const countB = materialTypesCount[b] ?? 0 return countB - countA }) const chipClasses = (active: boolean) => cn( 'rounded-md border transition-colors font-medium inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-sm', active ? 'text-white' : 'bg-white dark:bg-slate-800 text-slate-600 dark:text-slate-300 border-slate-200 dark:border-slate-700 hover:border-slate-400 dark:hover:border-slate-500' ) return (
{sortedTypeList.map((type) => { const isCustomTemplate = type.startsWith('template:') const displayName = isCustomTemplate ? type.split(':').slice(2).join(':') : type const normalized = isCustomTemplate ? null : normalizeMaterialType(type) const isActive = isCustomTemplate ? type.split(':')[1] === selectedTemplateId : Boolean(normalized) && selectedMaterialType === normalized const count = materialTypesCount[type] ?? 0 const titleLabel = count > 0 ? `${displayName}:${count}个素材` : displayName return ( ) })}
) } const handleHomeFilterSelection = (fieldKey: string, option: string | HomeFilterOption) => { const optionValue = typeof option === 'string' ? option : option.value setHomeFilterSelections((prev) => { const next = { ...prev } if (prev[fieldKey] === optionValue) { delete next[fieldKey] } else { next[fieldKey] = optionValue } return next }) } const getHomeFilterOptionLabel = (fieldKey: string, optionValue: string) => { const field = homeFilterFields[fieldKey] if (!field) return optionValue const match = field.options.find((option) => option.value === optionValue) return match?.label || optionValue } const clearHomeFilterSelection = (fieldKey: string) => { setHomeFilterSelections((prev) => { if (!(fieldKey in prev)) return prev const next = { ...prev } delete next[fieldKey] return next }) } const handleDownload = (id: string) => { // 如果已经在下载,直接返回 if (downloadingIds.has(id)) { return } console.log(`[Download] Starting download for material ${id}`) setDownloadingIds((prev) => new Set(prev).add(id)) setDownloadProgresses((prev) => ({ ...prev, [id]: 0 })) const scrollPosition = typeof window !== 'undefined' ? window.scrollY : 0 const xhr = new XMLHttpRequest() xhr.responseType = 'arraybuffer' // 监听下载进度 xhr.addEventListener('progress', (e) => { if (e.lengthComputable) { const percentComplete = Math.round((e.loaded / e.total) * 100) setDownloadProgresses((prev) => ({ ...prev, [id]: percentComplete })) } }) xhr.addEventListener('load', () => { console.log(`[Download] Response received, status: ${xhr.status}`) if (xhr.status === 200) { try { const contentType = xhr.getResponseHeader('Content-Type') || 'application/octet-stream' const blob = new Blob([xhr.response], { type: contentType }) // 从响应头获取文件名 const contentDisposition = xhr.getResponseHeader('Content-Disposition') let filename = 'material' if (contentDisposition) { const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) if (filenameMatch && filenameMatch[1]) { filename = decodeURIComponent(filenameMatch[1].replace(/['"]/g, '')) } } else if (contentType) { const extensionMap: Record = { 'application/zip': '.zip', 'video/mp4': '.mp4', 'video/webm': '.webm', 'video/x-msvideo': '.avi', 'video/quicktime': '.mov', } const matchedExt = Object.entries(extensionMap).find(([type]) => contentType.includes(type)) if (matchedExt) { filename += matchedExt[1] } } // 使用download属性直接下载,避免浏览器提示 const url = window.URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename a.style.display = 'none' document.body.appendChild(a) a.click() // 延迟移除,确保下载开始 setTimeout(() => { document.body.removeChild(a) window.URL.revokeObjectURL(url) }, 100) // 本地更新下载次数,避免整页刷新 setMaterials((prev) => prev.map((material) => material.id === id ? { ...material, downloadCount: (material.downloadCount || 0) + 1, } : material ) ) if (previewMaterial && previewMaterial.id === id) { setPreviewMaterial({ ...previewMaterial, downloadCount: (previewMaterial.downloadCount || 0) + 1, }) } // 排行榜可能发生变化,刷新排行榜数据 fetchMaterialRankings() if (typeof window !== 'undefined') { requestAnimationFrame(() => { window.scrollTo({ top: scrollPosition, behavior: 'auto' }) }) } } catch (error) { console.error('下载处理失败:', error) alert({ title: '下载失败', message: '下载失败,请稍后重试', variant: 'error', }) } } else { console.error(`[Download] Download failed with status ${xhr.status}`) try { const errorText = xhr.responseText console.error(`[Download] Error response:`, errorText) const error = JSON.parse(errorText) alert({ title: '下载失败', message: error.error || `下载失败 (状态码: ${xhr.status})`, variant: 'error', }) } catch (parseError) { console.error('[Download] Failed to parse error response:', parseError) alert({ title: '下载失败', message: `下载失败 (状态码: ${xhr.status})`, variant: 'error', }) } } // 清理下载状态 setDownloadingIds((prev) => { const next = new Set(prev) next.delete(id) return next }) setDownloadProgresses((prev) => { const next = { ...prev } delete next[id] return next }) }) xhr.addEventListener('error', (e) => { console.error('[Download] Network error:', e) alert({ title: '下载失败', message: '网络错误,请检查网络连接后重试', variant: 'error', }) setDownloadingIds((prev) => { const next = new Set(prev) next.delete(id) return next }) setDownloadProgresses((prev) => { const next = { ...prev } delete next[id] return next }) }) xhr.open('GET', `/api/materials/download?id=${id}`) xhr.send() } const handleLike = async (id: string) => { if (currentUserId) { const targetMaterial = materials.find((item) => item.id === id) || (previewMaterial && previewMaterial.id === id ? previewMaterial : null) if (targetMaterial && targetMaterial.userId === currentUserId) { alert({ title: '无法点赞', message: '不能给自己的素材点赞', variant: 'warning', }) return } } try { const response = await fetch('/api/materials/like', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ materialId: id }), }) if (response.ok) { const result = await response.json() // 更新本地状态 setMaterials((prev) => prev.map((m) => { if (m.id === id) { return { ...m, isLiked: result.liked, likeCount: result.liked ? m.likeCount + 1 : Math.max(0, m.likeCount - 1), } } return m }) ) // 如果预览的素材是当前素材,也更新预览状态 if (previewMaterial && previewMaterial.id === id) { setPreviewMaterial({ ...previewMaterial, isLiked: result.liked, likeCount: result.liked ? previewMaterial.likeCount + 1 : Math.max(0, previewMaterial.likeCount - 1), }) } // 点赞榜可能发生变化,重新获取排行榜(不会刷新列表) fetchMaterialRankings() } else { const error = await response.json() alert({ title: '操作失败', message: error.error || '操作失败', variant: 'error', }) } } catch (error) { console.error('Like error:', error) alert({ title: '操作失败', message: '操作失败,请稍后重试', variant: 'error', }) } } const handleFavorite = async (id: string) => { try { const response = await fetch('/api/materials/favorite', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ materialId: id }), }) if (!response.ok) { const error = await response.json() alert({ title: '操作失败', message: error.error || '更新收藏失败', variant: 'error', }) return } const result = await response.json() setMaterials((prev) => prev.map((material) => material.id === id ? { ...material, isFavorited: result.favorited } : material ) ) setPreviewMaterial((prev) => { if (!prev || prev.id !== id) return prev return { ...prev, isFavorited: result.favorited } }) } catch (error) { console.error('Favorite error:', error) alert({ title: '操作失败', message: '更新收藏失败,请稍后重试', variant: 'error', }) } } const handlePremiumToggle = async (id: string, nextState: boolean) => { try { const response = await fetch('/api/materials/premium', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ materialId: id, isPremium: nextState }), }) if (!response.ok) { const error = await response.json() alert({ title: '操作失败', message: error.error || '更新精品状态失败', variant: 'error', }) return } setMaterials((prev) => prev.map((material) => material.id === id ? { ...material, isPremium: nextState } : material ) ) setPreviewMaterial((prev) => { if (!prev || prev.id !== id) return prev return { ...prev, isPremium: nextState, rankingBadges: getRankingBadges(id, { isPremium: nextState }), } }) setMaterialRankings((prev) => ({ ...prev, premium: nextState ? Array.from(new Set([id, ...prev.premium])).slice(0, 10) : prev.premium.filter((item) => item !== id), })) } catch (error) { console.error('Premium toggle error:', error) alert({ title: '操作失败', message: '更新精品状态失败,请稍后重试', variant: 'error', }) } } // 删除素材(仅管理员有权限,按钮可根据系统设置控制是否对普通用户展示) const handleDelete = async (id: string) => { // 后端仍然会做权限验证,这里主要负责 UI 确认和反馈 const confirmed = await confirm({ title: '删除素材', message: '确定要删除这个素材吗?该操作不可恢复。', confirmText: '删除', cancelText: '取消', variant: 'danger', }) if (!confirmed) return try { const response = await fetch(`/api/materials?id=${encodeURIComponent(id)}`, { method: 'DELETE', }) if (response.ok) { alert({ title: '删除成功', message: '素材已删除。', variant: 'success', }) fetchMaterials() } else { const error = await response.json() alert({ title: '删除失败', message: error.error || '删除失败,请稍后重试', variant: 'error', }) } } catch (error) { console.error('Delete error:', error) alert({ title: '删除失败', message: '删除失败,请稍后重试', variant: 'error', }) } } // 编辑素材 const [editingMaterial, setEditingMaterial] = useState(null) const handleEdit = async (material: Material) => { // 获取完整的素材信息(包括templateId) try { const response = await fetch(`/api/materials/${material.id}`) if (response.ok) { const fullMaterial = await response.json() setEditingMaterial(fullMaterial as Material) } else { // 如果获取失败,使用现有数据 setEditingMaterial(material) } } catch (error) { console.error('Failed to fetch material details:', error) setEditingMaterial(material) } } // 检测URL中的edit参数,自动打开编辑弹窗 useEffect(() => { const editId = searchParams?.get('edit') if (editId && status === 'authenticated' && !editingMaterial) { // 从materials中查找对应的素材 const material = materials.find((m) => m.id === editId) if (material) { setEditingMaterial(material) // 清除URL参数 router.replace('/', { scroll: false }) } else { // 如果素材不在列表中,尝试从API获取 fetch(`/api/materials/${editId}`) .then((res) => res.json()) .then((data) => { if (data && data.id) { setEditingMaterial(data as Material) router.replace('/', { scroll: false }) } }) .catch((err) => { console.error('Failed to fetch material for edit:', err) }) } } }, [searchParams, status, materials, editingMaterial, router]) // 只在首次加载时显示加载中(NextAuth检查状态或首次加载素材) // 如果已经有素材了,即使loading为true也不显示加载中(避免tag切换时闪烁) const isInitialLoading = status === 'loading' || (status === 'authenticated' && !initialPasswordCheckDone) || (!isDefaultPassword && loading && materials.length === 0 && !homeFilterReady) if (isInitialLoading) { return (
加载中...
) } if (!session) { return null } if (isDefaultPassword) { return (
{}} isRequired onSuccess={() => { setIsDefaultPassword(false) setShowChangePasswordModal(false) window.location.reload() }} />
) } return (
setSearchKeyword(value), onOpenFilter: () => setShowFilterModal(true), onOpenUpload: () => setShowUploadModal(true), categoryItems: [ { key: '', label: '全部素材' }, ...materialTypes.map((type) => ({ key: type, label: type })), ], selectedCategoryKey: selectedMaterialType, onSelectCategory: (key) => { // 分类选择不算筛选,与tag按钮逻辑一致,可二次点击取消 handleMaterialTypeSelection(key) }, themeColor, }} />
{/* Header Section */}
{renderMaterialTypeChips()}
setSearchKeyword(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { setSearchQuery(searchKeyword.trim()) } }} placeholder="搜索SPU、店铺、场景类别、作者..." className={`${styles.inputStyles.base} pl-12 pr-10`} /> {searchKeyword && ( )}

排序方式

{hasActiveFilters && ( )}
{/* Materials Grid */} {materials.length === 0 ? (

{hasActiveFilters ? '没有找到匹配的素材' : '暂无素材,请上传素材'}

) : ( <>
{materials.slice(0, displayedCount).map((material) => { const rankingBadges = getRankingBadges(material.id, { isPremium: material.isPremium }) const isOwnMaterial = Boolean(currentUserId && material.userId === currentUserId) const materialRecord = material as unknown as Record const seenTagValues = new Set() const filterTags = orderedHomeFilterKeys .map((fieldKey) => { const rawValue = materialRecord[fieldKey] if (typeof rawValue !== 'string') return null const value = rawValue.trim() if (!value || seenTagValues.has(value)) return null seenTagValues.add(value) return { fieldKey, label: value } }) .filter((item): item is { fieldKey: string; label: string } => Boolean(item)) const resolvedMaterialKind = (material.materialKind || '').toUpperCase() const isVideoMaterial = resolvedMaterialKind === 'VIDEO' || material.materialType === '视频' const isAudioMaterial = resolvedMaterialKind === 'AUDIO' || material.materialType === '音频' const videoStatus = isVideoMaterial ? resolveVideoStatus(material.videoProcessingStatus || 'pending') : 'done' const videoStatusMeta = isVideoMaterial ? VIDEO_STATUS_META[videoStatus] : null const videoCover = material.videoCoverUrl || material.previewUrl const videoClipUrl = material.videoPreviewClipUrl || '' const canPlayClip = isVideoMaterial && videoStatus === 'done' && Boolean(videoClipUrl) const videoDurationLabel = isVideoMaterial ? formatVideoDuration(material.videoDuration ?? null) : '' const audioUrl = isAudioMaterial ? (material.audioUrl || `/api/materials/stream?id=${material.id}`) : '' return (
{ setPreviewMaterial({ ...material, rankingBadges: getRankingBadges(material.id, { isPremium: material.isPremium }), user: material.user, // 确保包含user信息用于头像显示 }) // 记录浏览历史 if (session?.user) { fetch('/api/user/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ materialId: material.id }), }).catch((err) => console.error('记录浏览历史失败:', err)) } }} onMouseEnter={() => { if (canPlayClip) { handleCardVideoHover(material.id, true) } }} onMouseLeave={() => { if (canPlayClip) { handleCardVideoHover(material.id, false) } }} > {isVideoMaterial ? ( canPlayClip ? ( <> {/* 视频封面覆盖层:当视频不在播放时显示用户上传的封面图 */} {!playingVideoIds.has(material.id) && (
{material.sceneName}
)} ) : ( {material.sceneName} ) ) : isAudioMaterial ? (

{material.productModel}

{material.audioDuration || '音频素材'}

) : ( {material.sceneName} )} {isVideoMaterial && !canPlayClip && (
{videoStatusMeta?.label || '预览生成中'}
)} {/* 型号标识 - 使用z-20确保在音频/视频覆盖层之上 */}
{/* SPU型号 */}
{material.productModel}
{/* 点赞按钮 - 使用z-20确保在音频/视频覆盖层之上,已点赞时始终显示 */} {!isOwnMaterial && currentUserId && ( )} {isVideoMaterial && videoDurationLabel && (
{videoDurationLabel}
)}
{rankingBadges.length > 0 && rankingBadges.map((badge) => ( {badge.label} ))} {getMaterialTemplateLabel(material) && ( {getMaterialTemplateLabel(material)} )} {filterTags.length > 0 && filterTags.map((tag, index) => { const color: TagColorToken = TAG_COLOR_PALETTE[index % TAG_COLOR_PALETTE.length] return ( {tag.label} ) })}
{/* 显示上传者信息,而不是素材内容的作者(如音频歌手) */} {material.user?.username || material.authorName}
{format(new Date(material.createdAt), 'yyyy-MM-dd')} {material.downloadCount} {material.likeCount}
handleEdit(material) : undefined } canEdit={ !!( session?.user && (material.userId === (session.user as any).id || (session.user as any).role === 'ADMIN') ) } onDelete={ session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) ? () => handleDelete(material.id) : undefined } canDelete={ !!( session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) ) } onShare={() => handleShare(material.id)} onLike={ !isOwnMaterial && currentUserId ? () => handleLike(material.id) : undefined } onDownload={() => handleDownload(material.id)} isDownloading={downloadingIds.has(material.id)} downloadProgress={downloadProgresses[material.id] || 0} />
) })} {/* 懒加载触发器 */} {materials.length > 0 && (displayedCount < materials.length || hasMoreMaterials) && (
{isLoadingMore ? (
加载中...
) : (
加载更多素材
)}
)} {materials.length > 0 && !hasMoreMaterials && displayedCount >= materials.length && (
暂无更多素材
)}
)}
{/* Upload Modal */} {showUploadModal && ( setShowUploadModal(false)} onSuccess={() => { setShowUploadModal(false) fetchMaterials() fetchHomeFilterFields() }} /> )} {/* Image Preview Modal */} setPreviewMaterial(null)} material={previewMaterial} onDownload={previewMaterial ? () => handleDownload(previewMaterial.id) : undefined} actionSection={ previewMaterial ? ( handleEdit(previewMaterial) : undefined } canEdit={ !!( session?.user && (previewMaterial.userId === (session.user as any).id || (session.user as any).role === 'ADMIN') ) } onDelete={ session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) ? () => handleDelete(previewMaterial.id) : undefined } canDelete={ !!( session?.user && (((session.user as any).role === 'ADMIN') || !homeDeleteButtonAdminOnly) ) } onShare={() => handleShare(previewMaterial.id)} onLike={ currentUserId && previewMaterial.userId !== currentUserId ? () => handleLike(previewMaterial.id) : undefined } onDownload={() => handleDownload(previewMaterial.id)} isDownloading={downloadingIds.has(previewMaterial.id)} downloadProgress={downloadProgresses[previewMaterial.id] || 0} canTogglePremium={(session?.user as any)?.role === 'ADMIN'} onTogglePremium={(nextState) => handlePremiumToggle(previewMaterial.id, nextState)} /> ) : undefined } /> {/* Share Notification */} {showShareNotification && (
分享链接已复制到剪贴板!
)} {/* 筛选弹窗 */} setShowFilterModal(false)} size="xlarge" title="筛选选项" showCloseButton closeOnOverlayClick closeOnEscape containerClassName="max-h-[92vh] w-full max-w-[760px]" bodyClassName="max-h-[80vh] overflow-y-auto pr-1" >
{showCategoryFilter && categoryOptions.length > 0 && (

{categoryLabel}

{categoryOptions.map((option) => ( ))}
)} {showSceneFilter && sceneOptions.length > 0 && (

{sceneLabel}

{sceneOptions.map((option) => ( ))}
)} {platformOptions.length > 0 && showPlatformFilter && (

{platformLabel}

{platformOptions.map((option) => ( ))}
)}

上传年份

{yearOptions.length === 0 ? ( 暂无年份数据 ) : ( yearOptions.map(({ year, count }) => ( )) )}

上传月份

{!selectedYear && (

请先选择年份,再选择具体月份

)}
{selectedYear && availableMonths.length === 0 && (

该年份暂无月份数据

)} {availableMonths.map((monthKey) => { const isActive = selectedMonth === monthKey const monthCount = monthCountsForSelectedYear[monthKey] ?? 0 return ( ) })}
{dynamicHomeFilterEntries.map(([fieldKey, field]) => (

{field.label}

{field.options.map((option) => { const optionValue = option.value const isActive = homeFilterSelections[fieldKey] === optionValue return ( ) })}
))}

调整完成后点击“应用筛选”,即可在首页看到最新结果。

{ setShowShareModal(false) setShareTokensError(null) }} size="medium" title="分享素材链接" showCloseButton closeOnOverlayClick closeOnEscape footer={
} >

新生成的链接会自动复制。下方列表用于查看已有链接状态,必要时可撤销。

分享链接默认 7 天有效。如浏览器拦截自动复制,可手动选中上方链接后使用 Ctrl+C(或 ⌘+C)

已有分享链接

{shareMaterialId && ( )}
{shareTokensError && (
{shareTokensError}
)} {shareTokensLoading ? (
正在加载分享链接...
) : shareTokens.length === 0 ? (
暂无历史分享链接。
) : (
{shareTokens.map((token) => { const statusMeta = getShareTokenStatusMeta(token.status) const canRevoke = token.status === 'active' return (
{statusMeta.label} 创建人:{token.createdByName || '未知'}
创建:{formatShareDateTime(token.createdAt)} 到期:{formatShareDateTime(token.expiresAt)} {token.revokedAt && ( 撤销:{formatShareDateTime(token.revokedAt)} )}
{canRevoke && ( )}
) })}
)}
{/* Edit Material Modal */} {editingMaterial && ( setEditingMaterial(null)} onSuccess={() => { setEditingMaterial(null) fetchMaterials() }} /> )} {/* 修改密码弹窗 */} { if (!isDefaultPassword) { setShowChangePasswordModal(false) } }} isRequired={isDefaultPassword} onSuccess={() => { setIsDefaultPassword(false) setShowChangePasswordModal(false) // 刷新页面以更新session window.location.reload() }} />
) }