2818 lines
111 KiB
TypeScript
2818 lines
111 KiB
TypeScript
'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>
|
||
)
|
||
}
|