flnxi-Wu/app/page.tsx
WuYongJie 542c7bcc49 feat: 优化自定义上传模板系统
- 修复内置模板验证问题(builtin-video-material等)
- 添加模板验证工具 lib/template-validator.ts
- 创建模板系统优化需求文档(30个详细需求)
- 优化上传API的模板验证逻辑
- 改进错误提示信息的清晰度
- 添加字段池和分类管理相关需求
- 完善自定义字段的创建、验证、存储逻辑
- 清理过期的 .specstory 和 .trae 文件
2025-12-13 14:26:43 +08:00

2475 lines
98 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 RankingPanel from '@/components/RankingPanel'
import AIChatWidget from '@/components/AIChatWidget'
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'
const TAG_COLOR_PALETTE = ['blue', 'green', 'orange', 'purple', 'indigo', 'pink', 'cyan'] as const
type TagColorToken = (typeof TAG_COLOR_PALETTE)[number]
const FALLBACK_MATERIAL_TYPES = ['3DMAX', 'PSD', 'AI 工作流', '视频素材模板', '模特素材模板', '其他素材模板', 'MD']
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' | '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[]
}
// 内置预制模板名称(用于区分“自定义模板”与“预制模板”)
const BUILTIN_TEMPLATE_NAMES = new Set([
'3DMAX 素材模板',
'PSD 素材模板',
'AI 工作流',
'视频素材模板',
'模特素材模板',
'其他素材模板',
])
// 计算素材卡片上用于区分模板的标签文案:
// - 如果绑定了“自定义模板”,优先使用模板名称(去掉尾部的“素材模板”)
// - 如果是预制模板或没有模板信息,继续使用 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 + '素材模板') ||
['3DMAX', 'PSD', 'AI 工作流', '视频', '模特', '其他'].includes(stripped)
// 非预制模板:认为是“自定义模板”,展示模板名称
if (!isBuiltin) {
return stripped || templateName
}
}
// 预制模板或没有模板信息时,使用 materialType
return material.materialType
}
type RankingBadge = {
label: string
gradient: 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[]
}
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 [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 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 [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-full text-xs font-medium transition-all border flex items-center gap-1.5 whitespace-nowrap',
isActive
? 'text-white shadow-sm shadow-slate-200/80 dark:shadow-black/40'
: '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: '精品', gradient: 'from-amber-400 via-orange-500 to-pink-500' })
}
if (downloadIndex > -1 && downloadIndex < 10) {
badges.push({ label: '下载榜', gradient: 'from-sky-500 via-blue-500 to-indigo-600' })
}
if (likeIndex > -1 && likeIndex < 10) {
badges.push({ label: '点赞榜', gradient: 'from-rose-500 via-fuchsia-500 to-purple-600' })
}
return badges
},
[materialRankings]
)
useEffect(() => {
if (status === 'unauthenticated') {
router.push('/login')
}
}, [status, router])
// 系统初始化检查和默认密码检查
useEffect(() => {
const checkSystemInit = async () => {
if (status !== 'authenticated') return
try {
// 1. 检查系统是否已初始化
const initResponse = await fetch('/api/system/init')
const initData = await initResponse.json()
if (!initData.initialized) {
// 系统未初始化,执行初始化
const initResult = await fetch('/api/system/init', {
method: 'POST'
})
const initResultData = await initResult.json()
if (initResultData.success) {
console.log('系统初始化成功')
}
}
// 检查初始管理员账号是否使用默认密码(只针对 admin@flnxi.com)
const passwordCheck = await fetch('/api/user/check-default-password')
const passwordData = await passwordCheck.json()
if (passwordData.isDefaultPassword) {
console.log('检测到初始管理员账号使用默认密码,强制修改密码')
setIsDefaultPassword(true)
setShowChangePasswordModal(true)
}
} catch (error) {
console.error('系统初始化检查失败:', error)
}
}
checkSystemInit()
}, [status, session])
// 检查是否有未完成的上传(断点续传)
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 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])
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 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
map[field.key] = {
key: field.key,
label: field.label || field.key,
type: field.type,
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,
selectedMonth,
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) {
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 {
// 暂停时也使用 requestAnimationFrame,避免与播放请求冲突
requestAnimationFrame(() => {
try {
videoEl.pause()
videoEl.currentTime = 0
} catch (error) {
// 忽略暂停时的错误
}
})
}
}, [])
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 handleShare = async (materialId: string) => {
const base = process.env.NEXT_PUBLIC_PORTAL_BASE_URL || window.location.origin
const url = `${base}/share/${materialId}`
setShareUrl(url)
setShowShareModal(true)
const copied = await copyTextToClipboard(url)
if (copied) {
setShowShareNotification(true)
setTimeout(() => setShowShareNotification(false), 2000)
}
}
// 实时搜索:输入时更新 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',
})
setShowShareModal(false)
return
}
alert({
title: '复制失败',
message: '浏览器未授予复制权限,请手动复制',
variant: 'error',
})
}
}
// TAG 按钮区域不再做宽度动画,改为简单左对齐,避免任何因测量/过渡导致的整体闪动
const renderMaterialTypeChips = () => {
const typeList = materialTypes.length > 0 ? materialTypes : FALLBACK_MATERIAL_TYPES
if (!typeList.length) return null
const chipClasses = (active: boolean) =>
cn(
'rounded-full border transition-all duration-200 font-medium flex items-center justify-center px-3.5 py-1.5 text-sm',
active
? 'text-white shadow-sm'
: '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={cn(chipClasses(selectedMaterialType === '' && selectedTemplateId === ''), 'relative group overflow-hidden')}
style={selectedMaterialType === '' && selectedTemplateId === '' ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
title={`全部:${totalMaterialsCount}个素材`}
>
{/* 隐藏的测量元素:按「全部 (数量)」计算按钮宽度,避免交互时溢出 */}
<span className="invisible inline-block whitespace-nowrap">
{totalMaterialsCount > 0 ? `全部 (${totalMaterialsCount})` : '全部'}
</span>
{/* 默认仅显示文字 */}
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="inline-block whitespace-nowrap transition-all duration-300 ease-in-out group-hover:opacity-0 group-hover:translate-x-[-10px]">
全部
</span>
</span>
{/* 悬停时显示「文字+数量」 */}
{totalMaterialsCount > 0 && (
<span className="absolute inset-0 flex items-center justify-center opacity-0 translate-x-[10px] group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 ease-in-out whitespace-nowrap pointer-events-none">
全部 ({totalMaterialsCount})
</span>
)}
</button>
{typeList.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={cn(chipClasses(isActive), 'relative group overflow-hidden')}
style={isActive ? { backgroundColor: themeColor, borderColor: themeColor } : undefined}
title={titleLabel}
>
{/* 隐藏的测量元素:按「文字 (数量)」计算按钮宽度 */}
<span className="invisible inline-block whitespace-nowrap">
{count > 0 ? `${displayName} (${count})` : displayName}
</span>
{/* 默认仅显示文字 */}
<span className="absolute inset-0 flex items-center justify-center pointer-events-none">
<span className="inline-block whitespace-nowrap transition-all duration-300 ease-in-out group-hover:opacity-0 group-hover:translate-x-[-10px]">
{displayName}
</span>
</span>
{/* 悬停时显示「文字+数量」 */}
{count > 0 && (
<span className="absolute inset-0 flex items-center justify-center opacity-0 translate-x-[10px] group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300 ease-in-out whitespace-nowrap pointer-events-none">
{displayName} ({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' || (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
}
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,
}}
/>
<RankingPanel />
<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={cn(styles.buttonStyles.primary, styles.buttonModifiers.compact, 'flex items-center gap-1')}
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-4 py-2 rounded-full text-sm font-medium transition-all ${
sortBy === 'createdAt'
? 'text-white'
: '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-4 py-2 rounded-full text-sm font-medium transition-all ${
sortBy === 'downloadCount'
? 'text-white'
: '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-4 py-2 rounded-full text-sm font-medium transition-all ${
sortBy === 'likeCount'
? 'text-white'
: '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-4 py-2 rounded-full text-sm font-medium transition-all 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 ml-4 pl-4 border-l border-slate-200 dark:border-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 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) : ''
return (
<div
key={material.id}
className="w-full rounded-2xl border border-slate-200/70 dark:border-slate-700/70 bg-white dark:bg-slate-900 shadow-lg shadow-slate-200/60 dark:shadow-black/40 overflow-hidden flex flex-col transition-transform duration-300 ease-out hover:-translate-y-1 hover:shadow-xl"
>
<div
className="relative w-full aspect-[3/4] bg-slate-100 dark:bg-slate-800 cursor-pointer group overflow-hidden rounded-t-2xl"
onClick={() => {
setPreviewMaterial({
...material,
rankingBadges: getRankingBadges(material.id, { isPremium: material.isPremium }),
user: material.user, // 确保包含user信息用于头像显示
})
}}
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-2xl w-full h-full transition-transform duration-500 group-hover:scale-[1.03] will-change-transform"
muted
loop
playsInline
preload="metadata"
>
<source src={videoClipUrl} />
</video>
) : (
<Image
src={videoCover || material.previewUrl}
alt={material.sceneName}
fill
unoptimized
className="object-cover rounded-t-2xl"
sizes="(max-width: 640px) 90vw, 320px"
priority={false}
/>
)
) : (
<Image
src={material.previewUrl}
alt={material.sceneName}
fill
unoptimized
className="object-cover rounded-t-2xl transition-transform duration-500 group-hover:scale-[1.03] will-change-transform"
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>
)}
<div className="absolute top-3 left-3 flex flex-col items-start gap-1.5">
{rankingBadges.length > 0 && (
<div className="flex flex-wrap gap-1">
{rankingBadges.map((badge) => (
<span
key={`card-badge-${material.id}-${badge.label}`}
className={cn(
'px-2.5 py-0.5 rounded-full bg-gradient-to-r text-[11px] font-semibold text-white shadow-sm',
badge.gradient
)}
>
{badge.label}
</span>
))}
</div>
)}
{/* 视频可预览角标按需求移除,避免视觉信息过载 */}
<div className="rounded-full bg-black/70 text-white px-2.5 py-1 text-xs font-semibold backdrop-blur">
{material.productModel}
</div>
</div>
{!isOwnMaterial && currentUserId && (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
handleLike(material.id)
}}
className={cn(
'absolute top-3 right-3 w-8 h-8 rounded-full flex items-center justify-center border transition-all duration-300 shadow-sm backdrop-blur',
material.isLiked
? 'opacity-100 bg-white text-rose-500 border-rose-200 shadow-rose-200/60'
: '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 className="absolute bottom-2 right-2 bg-black/70 text-white px-2 py-1 rounded text-xs font-medium opacity-0 group-hover:opacity-100 transition-opacity">
{format(new Date(material.createdAt), 'yyyy-MM-dd')}
</div>
</div>
<div className="p-4 flex flex-col flex-1">
<div className="flex items-center gap-1 mb-2 overflow-hidden whitespace-nowrap">
{getMaterialTemplateLabel(material) && (
<Tag
size="xs"
color="blue"
className="shrink-0 font-semibold bg-blue-500/90 dark:bg-blue-600/90 text-white border-0 shadow-sm"
>
{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 items-center gap-2">
<Avatar name={material.authorName} src={material.user?.avatar || undefined} size="xs" />
<span className="text-sm text-slate-700 dark:text-slate-200 font-medium">
{material.authorName}
</span>
</div>
<div className="flex items-center gap-3 text-xs text-slate-500 dark:text-slate-400">
<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-blue-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 === '')}
>
全部 ({totalMaterialsCount})
</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 === '')}
>
全部 ({totalMaterialsCount})
</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 === '')}
>
全部 ({totalMaterialsCount})
</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])}
>
全部 ({totalMaterialsCount})
</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)}
size="small"
title="分享素材链接"
showCloseButton
closeOnOverlayClick
closeOnEscape
footer={
<div className="flex justify-end">
<Button variant="secondary" size="md" onClick={() => setShowShareModal(false)}>
关闭
</Button>
</div>
}
>
<div className="space-y-4">
<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-2xl 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">
如浏览器拦截自动复制,可手动选中上方链接后使用 Ctrl+C(或 ⌘+C)
</p>
</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,
platformName: (editingMaterial as any).platformName,
}}
onClose={() => setEditingMaterial(null)}
onSuccess={() => {
setEditingMaterial(null)
fetchMaterials()
}}
/>
)}
{/* AI聊天助手 */}
<AIChatWidget />
{/* 修改密码弹窗 */}
<ChangePasswordModal
isOpen={showChangePasswordModal}
onClose={() => {
if (!isDefaultPassword) {
setShowChangePasswordModal(false)
}
}}
isRequired={isDefaultPassword}
onSuccess={() => {
setIsDefaultPassword(false)
setShowChangePasswordModal(false)
// 刷新页面以更新session
window.location.reload()
}}
/>
</div>
)
}