flnxi-Wu/app/page.tsx
2026-06-01 17:00:05 +08:00

2818 lines
111 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use client'
import { 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<Material[]>([])
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<string[]>([])
const [materialTypesCount, setMaterialTypesCount] = useState<Record<string, number>>({})
const [homeFilterFields, setHomeFilterFields] = useState<Record<string, HomeFilterFieldConfig>>({})
const [homeFilterSelections, setHomeFilterSelections] = useState<Record<string, string>>({})
const [homeFilterReady, setHomeFilterReady] = useState(false)
const [downloadingIds, setDownloadingIds] = useState<Set<string>>(new Set())
const [downloadProgresses, setDownloadProgresses] = useState<Record<string, number>>({})
const [selectedYear, setSelectedYear] = useState('')
const [selectedMonth, setSelectedMonth] = useState('')
const [months, setMonths] = useState<Array<{ value: string; label: string; count: number }>>([])
const [sortBy, setSortBy] = useState<'createdAt' | 'downloadCount' | 'likeCount'>('createdAt')
const [previewMaterial, setPreviewMaterial] = useState<Material | null>(null)
const [totalMaterialsCount, setTotalMaterialsCount] = useState(0)
const [currentPage, setCurrentPage] = useState(1)
const [shareUrl, setShareUrl] = useState<string | null>(null)
const [shareMaterialId, setShareMaterialId] = useState<string | null>(null)
const [shareTokens, setShareTokens] = useState<ShareTokenItem[]>([])
const [shareTokensLoading, setShareTokensLoading] = useState(false)
const [shareTokensError, setShareTokensError] = useState<string | null>(null)
const [showShareModal, setShowShareModal] = useState(false)
const [showShareNotification, setShowShareNotification] = useState(false)
const searchDebounceRef = useRef<number | null>(null)
const [isGridTransitioning, setIsGridTransitioning] = useState(false)
const videoRefs = useRef<Record<string, HTMLVideoElement | null>>({})
const [playingVideoIds, setPlayingVideoIds] = useState<Set<string>>(new Set())
// 音频播放状态
const audioRef = useRef<HTMLAudioElement | null>(null)
const [playingAudioId, setPlayingAudioId] = useState<string | null>(null)
const [audioProgress, setAudioProgress] = useState(0) // 0-100
const chipListRef = useRef<HTMLDivElement>(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<HTMLDivElement>(null) // 搜索区域引用(包含TAG和搜索框)
const searchStartScrollStateRef = useRef<boolean>(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<HTMLDivElement>(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<string, { count: number; months: Record<string, number> }> = {}
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<string, number> = {}
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<string, HomeFilterFieldConfig> = {}
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 (
<div className="flex flex-wrap w-full items-center justify-center gap-2.5">
<button
onClick={() => handleMaterialTypeSelection('')}
className={chipClasses(selectedMaterialType === '' && selectedTemplateId === '')}
style={selectedMaterialType === '' && selectedTemplateId === '' ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
title={`全部:${totalMaterialsCount}个素材`}
>
<span>全部</span>
{totalMaterialsCount > 0 && (
<span className="text-xs text-current/70">({totalMaterialsCount})</span>
)}
</button>
{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 (
<button
key={type}
onClick={() => handleMaterialTypeSelection(type)}
className={chipClasses(isActive)}
style={isActive ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
title={titleLabel}
>
<span>{displayName}</span>
{count > 0 && (
<span className="text-xs text-current/70">({count})</span>
)}
</button>
)
})}
</div>
)
}
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<string, string> = {
'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<Material | null>(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 (
<div className="min-h-screen flex items-center justify-center bg-slate-50 dark:bg-slate-900">
<div className="text-xl text-slate-600 dark:text-slate-400">加载中...</div>
</div>
)
}
if (!session) {
return null
}
if (isDefaultPassword) {
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header session={session} />
<main className="container mx-auto max-w-7xl px-4 py-8">
<ChangePasswordModal
isOpen
onClose={() => {}}
isRequired
onSuccess={() => {
setIsDefaultPassword(false)
setShowChangePasswordModal(false)
window.location.reload()
}}
/>
</main>
</div>
)
}
return (
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
<Header
session={session}
compactMode={{
isScrolled,
searchKeyword,
onSearchChange: (value) => 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,
}}
/>
<main className="container mx-auto px-4 py-8 max-w-7xl">
{/* Header Section */}
<div className="mb-8 space-y-6">
<div ref={searchSectionRef} className="space-y-4 mt-16">
<div className="flex flex-wrap.items-center justify-center gap-3">
{renderMaterialTypeChips()}
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[240px]">
<input
type="text"
value={searchKeyword}
onChange={(e) => setSearchKeyword(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
setSearchQuery(searchKeyword.trim())
}
}}
placeholder="搜索SPU、店铺、场景类别、作者..."
className={`${styles.inputStyles.base} pl-12 pr-10`}
/>
<svg
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{searchKeyword && (
<button
onClick={() => {
setSearchKeyword('')
setSearchQuery('')
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300"
>
<svg className="w-4 h-4" 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>
<button
onClick={() => setShowFilterModal(true)}
className={cn(
styles.buttonStyles.secondary,
styles.buttonModifiers.compact,
'flex items-center gap-2 relative'
)}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z"
/>
</svg>
筛选
{filterIndicatorCount > 0 && (
<span className="absolute -top-1 -right-1 min-w-[20px] h-5 px-1 bg-red-500 text-white text-[10px] rounded-full flex items-center justify-center">
{filterIndicatorCount}
</span>
)}
</button>
<button
onClick={() => setShowUploadModal(true)}
className="flex items-center gap-1 rounded-md px-3 py-1.5 text-sm font-medium text-white transition-colors hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-50"
style={{ backgroundColor: themeColor }}
>
上传素材
</button>
</div>
</div>
<div className="mb-2">
<div className="flex items-center justify-between mb-2">
<h2 className="text-sm font-medium text-slate-700 dark:text-slate-300">排序方式</h2>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSortBy('createdAt')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${
sortBy === 'createdAt'
? 'text-white border-transparent'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
style={sortBy === 'createdAt' ? { backgroundColor: themeColor } : {}}
>
最新上传
</button>
<button
onClick={() => setSortBy('downloadCount')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${
sortBy === 'downloadCount'
? 'text-white border-transparent'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
style={sortBy === 'downloadCount' ? { backgroundColor: themeColor } : {}}
>
下载最多
</button>
<button
onClick={() => setSortBy('likeCount')}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors border ${
sortBy === 'likeCount'
? 'text-white border-transparent'
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'
}`}
style={sortBy === 'likeCount' ? { backgroundColor: themeColor } : {}}
>
点赞最多
</button>
{hasActiveFilters && (
<button
type="button"
onClick={() => {
setSearchKeyword('')
setSearchQuery('')
setSelectedCategory('')
setSelectedScene('')
setSelectedPlatform('')
setSelectedYear('')
setSelectedMonth('')
setHomeFilterSelections({})
}}
className="px-3 py-1.5 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700"
>
清除筛选
</button>
)}
</div>
</div>
</div>
{/* Materials Grid */}
{materials.length === 0 ? (
<div className="text-center py-16">
<p className="text-slate-500 dark:text-slate-400 text-lg">
{hasActiveFilters ? '没有找到匹配的素材' : '暂无素材,请上传素材'}
</p>
</div>
) : (
<>
<div className="mt-6">
<div
className={cn(
'grid gap-6 grid-cols-1 sm:grid-cols-2 lg:grid-cols-4',
// 使用更平滑的过渡,避免闪烁
isGridTransitioning && 'opacity-70 pointer-events-none',
!isGridTransitioning && 'opacity-100 pointer-events-auto transition-opacity duration-150 ease-in-out'
)}
>
{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<string, unknown>
const seenTagValues = new Set<string>()
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 (
<div
key={material.id}
className="w-full rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 overflow-hidden flex flex-col transition-colors hover:border-slate-300 dark:hover:border-slate-600"
>
<div
className="relative w-full aspect-[3/4] bg-slate-100 dark:bg-slate-800 cursor-pointer group overflow-hidden rounded-t-lg"
onClick={() => {
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 ? (
<>
<video
ref={(el) => {
if (el) {
videoRefs.current[material.id] = el
} else {
delete videoRefs.current[material.id]
}
}}
poster={videoCover || material.previewUrl}
className="object-cover rounded-t-lg w-full h-full"
muted
loop
playsInline
preload="metadata"
>
<source src={videoClipUrl} />
</video>
{/* 视频封面覆盖层:当视频不在播放时显示用户上传的封面图 */}
{!playingVideoIds.has(material.id) && (
<div className="absolute inset-0 z-10 pointer-events-none">
<Image
src={videoCover || material.previewUrl}
alt={material.sceneName}
fill
unoptimized
className="object-cover rounded-t-lg"
sizes="(max-width: 640px) 90vw, 320px"
priority={false}
/>
</div>
)}
</>
) : (
<Image
src={videoCover || material.previewUrl}
alt={material.sceneName}
fill
unoptimized
className="object-cover rounded-t-lg"
sizes="(max-width: 640px) 90vw, 320px"
priority={false}
/>
)
) : isAudioMaterial ? (
<div className="absolute inset-0 flex flex-col justify-between bg-slate-100 p-4 dark:bg-slate-800">
<div className="flex items-start gap-3">
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900">
<svg className="h-5 w-5 text-slate-500 dark:text-slate-300" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2z" />
</svg>
</div>
<div className="min-w-0">
<p className="line-clamp-2 text-sm font-medium text-slate-900 dark:text-white">{material.productModel}</p>
<p className="mt-1 text-xs text-slate-500 dark:text-slate-400">
{material.audioDuration || '音频素材'}
</p>
</div>
</div>
<div className="space-y-3">
<div className="h-1.5 overflow-hidden rounded bg-slate-200 dark:bg-slate-700">
<div
className="h-full bg-slate-500 transition-[width] duration-100 dark:bg-slate-400"
style={{ width: playingAudioId === material.id ? `${audioProgress}%` : '0%' }}
/>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
handleAudioPlay(material.id, audioUrl)
}}
className="inline-flex h-9 items-center gap-2 rounded-md border border-slate-300 bg-white px-3 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-600 dark:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800"
>
{playingAudioId === material.id ? (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
) : (
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
)}
<span>{playingAudioId === material.id ? '暂停' : '播放'}</span>
</button>
</div>
</div>
) : (
<Image
src={material.previewUrl}
alt={material.sceneName}
fill
unoptimized
className="object-cover rounded-t-lg"
sizes="(max-width: 640px) 90vw, 320px"
priority={false}
/>
)}
{isVideoMaterial && !canPlayClip && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-slate-950/50 text-white text-sm font-semibold">
<svg
className="w-7 h-7"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.868v4.264a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.8}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<span>{videoStatusMeta?.label || '预览生成中'}</span>
</div>
)}
{/* 型号标识 - 使用z-20确保在音频/视频覆盖层之上 */}
<div className="absolute top-3 left-3 flex flex-col items-start gap-1.5 z-20">
{/* SPU型号 */}
<div className="rounded-md border border-slate-200 bg-white/90 text-slate-800 dark:border-slate-700 dark:bg-slate-900/90 dark:text-slate-100 px-2 py-1 text-xs font-medium">
{material.productModel}
</div>
</div>
{/* 点赞按钮 - 使用z-20确保在音频/视频覆盖层之上,已点赞时始终显示 */}
{!isOwnMaterial && currentUserId && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
handleLike(material.id)
}}
className={cn(
'absolute top-3 right-3 w-8 h-8 rounded-md flex items-center justify-center border transition-colors z-20',
material.isLiked
? 'opacity-100 bg-white text-rose-500 border-rose-200'
: 'opacity-0 group-hover:opacity-100 bg-white/85 text-slate-600 border-white/60 hover:text-rose-500'
)}
aria-label={material.isLiked ? '取消点赞' : '点赞'}
>
<svg
className="w-5 h-5"
viewBox="0 0 24 24"
fill={material.isLiked ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth={material.isLiked ? 0 : 1.8}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
/>
</svg>
</button>
)}
{isVideoMaterial && videoDurationLabel && (
<div className="absolute bottom-3 left-3 bg-black/75 text-white px-2 py-1 rounded text-xs font-semibold flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.8} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{videoDurationLabel}
</div>
)}
</div>
<div className="p-4 flex flex-col flex-1">
<div className="flex items-center gap-1 mb-2 overflow-hidden whitespace-nowrap">
{rankingBadges.length > 0 && rankingBadges.map((badge) => (
<span
key={`card-badge-${material.id}-${badge.label}`}
className="shrink-0 rounded-md border border-slate-200 bg-slate-50 px-2 py-0.5 text-[11px] font-medium text-slate-600 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
>
{badge.label}
</span>
))}
{getMaterialTemplateLabel(material) && (
<Tag
size="xs"
color="blue"
className="shrink-0 font-medium"
>
{getMaterialTemplateLabel(material)}
</Tag>
)}
{filterTags.length > 0 && filterTags.map((tag, index) => {
const color: TagColorToken = TAG_COLOR_PALETTE[index % TAG_COLOR_PALETTE.length]
return (
<Tag
key={`${material.id}-${tag.fieldKey}`}
size="xs"
color={color}
className="shrink-0"
>
{tag.label}
</Tag>
)
})}
</div>
<div className="flex items-center justify-between mt-3 gap-3">
<div className="flex min-w-0 items-center gap-2">
{/* 显示上传者信息,而不是素材内容的作者(如音频歌手) */}
<Avatar name={material.user?.username || material.authorName} src={material.user?.avatar || undefined} size="xs" />
<span className="truncate text-sm text-slate-700 dark:text-slate-200 font-medium">
{material.user?.username || material.authorName}
</span>
</div>
<div className="flex shrink-0 items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
<span>{format(new Date(material.createdAt), 'yyyy-MM-dd')}</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
{material.downloadCount}
</span>
<span className="flex items-center gap-1">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
{material.likeCount}
</span>
</div>
</div>
<MaterialActionButtons
className="mt-4"
layout="card"
material={material}
themeColor={themeColor}
inlineLikeButton={false}
onEdit={
session?.user &&
(material.userId === (session.user as any).id || (session.user as any).role === 'ADMIN')
? () => 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}
/>
</div>
</div>
)
})}
{/* 懒加载触发器 */}
{materials.length > 0 && (displayedCount < materials.length || hasMoreMaterials) && (
<div ref={loadMoreRef} className="w-full h-20 flex items-center justify-center">
{isLoadingMore ? (
<div className="text-slate-500 dark:text-slate-400 text-sm flex items-center gap-2">
<span className="inline-block h-3 w-3 rounded-full border-2 border-slate-300 border-t-slate-500 animate-spin" />
加载中...
</div>
) : (
<div className="text-slate-400 dark:text-slate-500 text-sm">加载更多素材</div>
)}
</div>
)}
{materials.length > 0 && !hasMoreMaterials && displayedCount >= materials.length && (
<div className="w-full py-6 text-center text-sm text-slate-500 dark:text-slate-400">
暂无更多素材
</div>
)}
</div>
</div>
</>
)}
</main>
{/* Upload Modal */}
{showUploadModal && (
<UploadModal
onClose={() => setShowUploadModal(false)}
onSuccess={() => {
setShowUploadModal(false)
fetchMaterials()
fetchHomeFilterFields()
}}
/>
)}
{/* Image Preview Modal */}
<MaterialPreviewModal
isOpen={!!previewMaterial}
onClose={() => setPreviewMaterial(null)}
material={previewMaterial}
onDownload={previewMaterial ? () => handleDownload(previewMaterial.id) : undefined}
actionSection={
previewMaterial ? (
<MaterialActionButtons
layout="modal"
material={previewMaterial}
themeColor={themeColor}
onEdit={
session?.user &&
(previewMaterial.userId === (session.user as any).id ||
(session.user as any).role === 'ADMIN')
? () => 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 && (
<div className="fixed top-20 left-1/2 transform -translate-x-1/2 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-[200] flex items-center gap-2 animate-fade-in">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span>分享链接已复制到剪贴板!</span>
</div>
)}
{/* 筛选弹窗 */}
<Modal
isOpen={showFilterModal}
onClose={() => 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"
>
<div className="flex flex-col min-h-full gap-5">
<div className="flex flex-col gap-4 flex-1 pb-4">
{showCategoryFilter && categoryOptions.length > 0 && (
<section className={filterCardClass}>
<p className={filterCardTitleClass}>{categoryLabel}</p>
<div className="flex flex-wrap gap-1.5 mt-2">
<button
onClick={() => setSelectedCategory('')}
className={buildFilterPillClass(selectedCategory === '')}
style={getFilterPillStyle(selectedCategory === '')}
>
全部 ({categoryField?.totalCount ?? 0})
</button>
{categoryOptions.map((option) => (
<button
key={option.value}
onClick={() => setSelectedCategory(option.value === selectedCategory ? '' : option.value)}
className={buildFilterPillClass(selectedCategory === option.value)}
style={getFilterPillStyle(selectedCategory === option.value)}
>
{option.label}
<span className="text-[11px] text-current/70">({option.count ?? 0})</span>
</button>
))}
</div>
</section>
)}
{showSceneFilter && sceneOptions.length > 0 && (
<section className={filterCardClass}>
<p className={filterCardTitleClass}>{sceneLabel}</p>
<div className="flex flex-wrap gap-1.5 mt-2">
<button
onClick={() => setSelectedScene('')}
className={buildFilterPillClass(selectedScene === '')}
style={getFilterPillStyle(selectedScene === '')}
>
全部 ({sceneField?.totalCount ?? 0})
</button>
{sceneOptions.map((option) => (
<button
key={option.value}
onClick={() => setSelectedScene(option.value === selectedScene ? '' : option.value)}
className={buildFilterPillClass(selectedScene === option.value)}
style={getFilterPillStyle(selectedScene === option.value)}
>
{option.label}
<span className="text-[11px] text-current/70">({option.count ?? 0})</span>
</button>
))}
</div>
</section>
)}
{platformOptions.length > 0 && showPlatformFilter && (
<section className={filterCardClass}>
<p className={filterCardTitleClass}>{platformLabel}</p>
<div className="flex flex-wrap gap-1.5 mt-2">
<button
onClick={() => setSelectedPlatform('')}
className={buildFilterPillClass(selectedPlatform === '')}
style={getFilterPillStyle(selectedPlatform === '')}
>
全部 ({platformField?.totalCount ?? 0})
</button>
{platformOptions.map((option) => (
<button
key={option.value}
onClick={() => setSelectedPlatform(option.value === selectedPlatform ? '' : option.value)}
className={buildFilterPillClass(selectedPlatform === option.value)}
style={getFilterPillStyle(selectedPlatform === option.value)}
>
{option.label}
<span className="text-[11px] text-current/70">({option.count ?? 0})</span>
</button>
))}
</div>
</section>
)}
<section className={filterCardClass}>
<p className={filterCardTitleClass}>上传年份</p>
<div className="flex flex-wrap gap-1.5 mt-2">
<button
onClick={() => {
setSelectedYear('')
setSelectedMonth('')
}}
className={buildFilterPillClass(selectedYear === '')}
style={getFilterPillStyle(selectedYear === '')}
>
全部 ({totalMaterialsCount})
</button>
{yearOptions.length === 0 ? (
<span className="text-xs text-slate-500 dark:text-slate-400">暂无年份数据</span>
) : (
yearOptions.map(({ year, count }) => (
<button
key={year}
onClick={() => {
if (selectedYear === year) {
setSelectedYear('')
setSelectedMonth('')
} else {
setSelectedYear(year)
setSelectedMonth('')
}
}}
className={buildFilterPillClass(selectedYear === year)}
style={getFilterPillStyle(selectedYear === year)}
>
{year} 年
<span className="text-[11px] text-current/70">({count})</span>
</button>
))
)}
</div>
</section>
<section className={filterCardClass}>
<p className={filterCardTitleClass}>上传月份</p>
{!selectedYear && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1">请先选择年份,再选择具体月份</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
<button
onClick={() => setSelectedMonth('')}
disabled={!selectedYear}
className={cn(
buildFilterPillClass(selectedMonth === ''),
!selectedYear && 'opacity-60 cursor-not-allowed'
)}
style={getFilterPillStyle(selectedMonth === '' && Boolean(selectedYear))}
>
全部
{selectedYear && (
<span className="text-[11px] text-current/70">
({yearMonthStats[selectedYear]?.count ?? 0})
</span>
)}
</button>
{selectedYear && availableMonths.length === 0 && (
<p className="text-xs text-slate-500 dark:text-slate-400">该年份暂无月份数据</p>
)}
{availableMonths.map((monthKey) => {
const isActive = selectedMonth === monthKey
const monthCount = monthCountsForSelectedYear[monthKey] ?? 0
return (
<button
key={monthKey}
onClick={() => {
if (!selectedYear) return
setSelectedMonth((prev) => (prev === monthKey ? '' : monthKey))
}}
disabled={!selectedYear}
className={cn(
buildFilterPillClass(isActive),
!selectedYear && 'cursor-not-allowed'
)}
style={getFilterPillStyle(isActive)}
>
{parseInt(monthKey, 10)}月
<span className="text-[11px] text-current/70">({monthCount})</span>
</button>
)
})}
</div>
</section>
{dynamicHomeFilterEntries.map(([fieldKey, field]) => (
<section key={fieldKey} className={filterCardClass}>
<p className={filterCardTitleClass}>{field.label}</p>
<div className="flex flex-wrap gap-1.5 mt-2">
<button
onClick={() => clearHomeFilterSelection(fieldKey)}
className={buildFilterPillClass(!homeFilterSelections[fieldKey])}
style={getFilterPillStyle(!homeFilterSelections[fieldKey])}
>
全部 ({field.totalCount ?? 0})
</button>
{field.options.map((option) => {
const optionValue = option.value
const isActive = homeFilterSelections[fieldKey] === optionValue
return (
<button
key={`${fieldKey}-${optionValue}`}
onClick={() => handleHomeFilterSelection(fieldKey, option)}
className={buildFilterPillClass(isActive)}
style={getFilterPillStyle(isActive)}
>
{option.label}
{typeof option.count === 'number' && (
<span className="text-[11px] text-current/70">({option.count})</span>
)}
</button>
)
})}
</div>
</section>
))}
</div>
<div className="sticky bottom-0 right-0 pt-3 bg-transparent">
<div className="border-t border-slate-200 dark:border-slate-700 pt-3 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p className="text-xs text-slate-500 dark:text-slate-400">
调整完成后点击“应用筛选”,即可在首页看到最新结果。
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:gap-2 sm:items-center">
<Button
variant="secondary"
size="md"
onClick={() => {
setSelectedCategory('')
setSelectedScene('')
setSelectedPlatform('')
setSelectedMonth('')
setHomeFilterSelections({})
// 注意:selectedMaterialType是分类选择,不算筛选,不清除
}}
className="sm:min-w-[140px]"
>
清除全部
</Button>
<Button
variant="primary"
size="md"
onClick={() => setShowFilterModal(false)}
style={{ backgroundColor: themeColor }}
className="sm:min-w-[140px]"
>
应用筛选
</Button>
</div>
</div>
</div>
</div>
</Modal>
<Modal
isOpen={showShareModal}
onClose={() => {
setShowShareModal(false)
setShareTokensError(null)
}}
size="medium"
title="分享素材链接"
showCloseButton
closeOnOverlayClick
closeOnEscape
footer={
<div className="flex justify-end">
<Button
variant="secondary"
size="md"
onClick={() => {
setShowShareModal(false)
setShareTokensError(null)
}}
>
关闭
</Button>
</div>
}
>
<div className="space-y-5">
<p className="text-sm text-slate-600 dark:text-slate-300">
新生成的链接会自动复制。下方列表用于查看已有链接状态,必要时可撤销。
</p>
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/40 px-3 py-2">
<input
readOnly
value={shareUrl ?? ''}
className="flex-1 bg-transparent text-sm text-slate-800 dark:text-slate-100 outline-none"
/>
<Button variant="primary" size="sm" onClick={copyShareUrl}>
复制链接
</Button>
</div>
<p className="text-[11px] text-slate-500 dark:text-slate-400">
分享链接默认 7 天有效。如浏览器拦截自动复制,可手动选中上方链接后使用 Ctrl+C(或 ⌘+C)
</p>
</div>
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">已有分享链接</h3>
{shareMaterialId && (
<Button
variant="ghost"
size="sm"
onClick={() => fetchShareTokens(shareMaterialId)}
disabled={shareTokensLoading}
>
刷新
</Button>
)}
</div>
{shareTokensError && (
<div className="mb-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600 dark:border-red-800 dark:bg-red-900/20 dark:text-red-300">
{shareTokensError}
</div>
)}
{shareTokensLoading ? (
<div className="rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
正在加载分享链接...
</div>
) : shareTokens.length === 0 ? (
<div className="rounded-lg border border-slate-200 dark:border-slate-700 px-3 py-4 text-sm text-slate-500 dark:text-slate-400">
暂无历史分享链接。
</div>
) : (
<div className="space-y-2">
{shareTokens.map((token) => {
const statusMeta = getShareTokenStatusMeta(token.status)
const canRevoke = token.status === 'active'
return (
<div
key={token.id}
className="rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-3 py-3"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className={cn('inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium', statusMeta.className)}>
{statusMeta.label}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
创建人:{token.createdByName || '未知'}
</span>
</div>
<div className="grid gap-1 text-xs text-slate-500 dark:text-slate-400 sm:grid-cols-2">
<span>创建:{formatShareDateTime(token.createdAt)}</span>
<span>到期:{formatShareDateTime(token.expiresAt)}</span>
{token.revokedAt && (
<span className="sm:col-span-2">撤销:{formatShareDateTime(token.revokedAt)}</span>
)}
</div>
</div>
{canRevoke && (
<Button
variant="danger"
size="sm"
onClick={() => handleRevokeShareToken(token.id)}
className="shrink-0"
>
撤销
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
</Modal>
{/* Edit Material Modal */}
{editingMaterial && (
<EditMaterialModal
material={{
id: editingMaterial.id,
previewUrl: editingMaterial.previewUrl,
productModel: editingMaterial.productModel,
categoryName: editingMaterial.categoryName,
sceneName: editingMaterial.sceneName,
materialType: editingMaterial.materialType || '3DMAX',
renderer: editingMaterial.renderer || 'CR',
authorName: editingMaterial.authorName,
brandName: editingMaterial.brandName,
seriesName: editingMaterial.seriesName,
skuCode: editingMaterial.skuCode,
materialTexture: editingMaterial.materialTexture,
renderTime: editingMaterial.renderTime,
imageResolution: editingMaterial.imageResolution,
colorMode: editingMaterial.colorMode,
templateId: (editingMaterial as any).templateId,
templateFields: (editingMaterial as any).templateFields,
platformName: (editingMaterial as any).platformName,
// 音频相关字段
audioDuration: (editingMaterial as any).audioDuration,
audioCategory: (editingMaterial as any).audioCategory,
audioScene: (editingMaterial as any).audioScene,
}}
onClose={() => setEditingMaterial(null)}
onSuccess={() => {
setEditingMaterial(null)
fetchMaterials()
}}
/>
)}
{/* 修改密码弹窗 */}
<ChangePasswordModal
isOpen={showChangePasswordModal}
onClose={() => {
if (!isDefaultPassword) {
setShowChangePasswordModal(false)
}
}}
isRequired={isDefaultPassword}
onSuccess={() => {
setIsDefaultPassword(false)
setShowChangePasswordModal(false)
// 刷新页面以更新session
window.location.reload()
}}
/>
</div>
)
}