670 lines
28 KiB
TypeScript
670 lines
28 KiB
TypeScript
/**
|
||
* 个人中心页面
|
||
*
|
||
* 用户个人信息管理页面
|
||
* 包含用户信息展示、头像设置、上传的素材、下载记录等
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-11-25
|
||
* @page
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { useState, useEffect } from 'react'
|
||
import Image from 'next/image'
|
||
import { useThemeStyles, cn } from '@/lib/ui-styles'
|
||
import Header from '@/components/Header'
|
||
import Avatar from '@/components/ui/Avatar'
|
||
import Modal from '@/components/ui/Modal'
|
||
import Button from '@/components/ui/Button'
|
||
import MaterialPreviewModal from '@/components/MaterialPreviewModal'
|
||
import MaterialActionButtons from '@/components/MaterialActionButtons'
|
||
import { useDialog } from '@/components/ui/DialogProvider'
|
||
|
||
interface UserProfile {
|
||
id: string
|
||
username: string
|
||
email: string
|
||
avatar: string | null
|
||
department: string
|
||
realName: string | null
|
||
phone: string | null
|
||
createdAt: string
|
||
}
|
||
|
||
interface Material {
|
||
id: string
|
||
userId: string
|
||
productModel: string
|
||
previewUrl: string
|
||
displayImages?: string[]
|
||
materialType?: string
|
||
materialKind?: 'IMAGE' | 'VIDEO' | 'OTHER'
|
||
categoryName?: string
|
||
sceneName?: string
|
||
renderer?: string
|
||
authorName?: string
|
||
downloadCount: number
|
||
likeCount: number
|
||
createdAt: string
|
||
isPremium?: boolean
|
||
isFavorited?: boolean
|
||
isLiked?: boolean
|
||
zipFileName?: string
|
||
zipFileSize?: string | number
|
||
videoCoverUrl?: string | null
|
||
videoPreview540Url?: string | null
|
||
videoPreviewClipUrl?: string | null
|
||
videoProcessingStatus?: string | null
|
||
videoDuration?: number | null
|
||
user?: {
|
||
username?: string
|
||
avatar?: string | null
|
||
}
|
||
rankingBadges?: Array<{ label: string; gradient: string }>
|
||
}
|
||
|
||
export default function ProfilePage() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const styles = useThemeStyles()
|
||
const { alert } = useDialog()
|
||
const [profile, setProfile] = useState<UserProfile | null>(null)
|
||
const [materials, setMaterials] = useState<Material[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const [showAvatarModal, setShowAvatarModal] = useState(false)
|
||
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||
const [avatarPreview, setAvatarPreview] = useState<string>('')
|
||
const [uploading, setUploading] = useState(false)
|
||
const [previewMaterial, setPreviewMaterial] = useState<Material | null>(null)
|
||
const [favoriteMaterials, setFavoriteMaterials] = useState<Material[]>([])
|
||
const [downloadingIds, setDownloadingIds] = useState<Set<string>>(new Set())
|
||
const [downloadProgresses, setDownloadProgresses] = useState<Record<string, number>>({})
|
||
|
||
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 xhr = new XMLHttpRequest()
|
||
|
||
// 监听下载进度
|
||
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)
|
||
|
||
// 刷新列表以更新下载次数
|
||
fetchMaterials()
|
||
fetchLikedMaterials()
|
||
} 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.responseType = 'arraybuffer'
|
||
xhr.send()
|
||
}
|
||
|
||
useEffect(() => {
|
||
if (status === 'unauthenticated') {
|
||
router.push('/login')
|
||
return
|
||
}
|
||
|
||
if (status === 'authenticated' && session) {
|
||
fetchProfile()
|
||
fetchMaterials()
|
||
fetchLikedMaterials()
|
||
}
|
||
}, [status, session, router])
|
||
|
||
const fetchProfile = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/profile')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setProfile(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch profile:', error)
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
const fetchMaterials = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/materials')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setMaterials(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch materials:', error)
|
||
}
|
||
}
|
||
|
||
const fetchLikedMaterials = async () => {
|
||
try {
|
||
const res = await fetch('/api/user/likes')
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setFavoriteMaterials(data)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch liked materials:', error)
|
||
}
|
||
}
|
||
|
||
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0]
|
||
if (file) {
|
||
if (!file.type.startsWith('image/')) {
|
||
alert({ title: '提示', message: '请选择图片文件', variant: 'warning' })
|
||
return
|
||
}
|
||
setAvatarFile(file)
|
||
setAvatarPreview(URL.createObjectURL(file))
|
||
setShowAvatarModal(true)
|
||
}
|
||
}
|
||
|
||
const handleAvatarUpload = async () => {
|
||
if (!avatarFile) return
|
||
|
||
setUploading(true)
|
||
try {
|
||
const formData = new FormData()
|
||
formData.append('avatar', avatarFile)
|
||
|
||
const res = await fetch('/api/user/avatar', {
|
||
method: 'POST',
|
||
body: formData,
|
||
})
|
||
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setProfile(prev => prev ? { ...prev, avatar: data.avatarUrl } : null)
|
||
setShowAvatarModal(false)
|
||
setAvatarFile(null)
|
||
setAvatarPreview('')
|
||
// 刷新页面以更新 session 中的头像(JWT callback 会从数据库获取最新头像)
|
||
// 由于 JWT callback 每5分钟自动刷新一次,也可以等待自动刷新
|
||
window.location.reload()
|
||
} else {
|
||
const error = await res.json()
|
||
alert({ title: '上传失败', message: error.error || '上传失败', variant: 'error' })
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to upload avatar:', error)
|
||
alert({ title: '上传失败', message: '上传失败,请稍后重试', variant: 'error' })
|
||
} finally {
|
||
setUploading(false)
|
||
}
|
||
}
|
||
|
||
if (status === 'loading' || loading) {
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<p className="text-slate-500 dark:text-slate-400">加载中...</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!session || !profile) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||
<Header session={session} />
|
||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
{/* 用户信息卡片 */}
|
||
<div className={cn(styles.cardStyles.base, 'mb-6')}>
|
||
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
|
||
{/* 头像 */}
|
||
<div className="relative">
|
||
<Avatar
|
||
src={profile.avatar || undefined}
|
||
name={profile.username}
|
||
size="xl"
|
||
/>
|
||
<label className="absolute bottom-0 right-0 w-8 h-8 bg-blue-600 text-white rounded-full flex items-center justify-center cursor-pointer hover:bg-blue-700 transition-colors">
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
|
||
</svg>
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleAvatarSelect}
|
||
className="hidden"
|
||
/>
|
||
</label>
|
||
</div>
|
||
|
||
{/* 用户信息 */}
|
||
<div className="flex-1 text-center sm:text-left">
|
||
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
||
{profile.realName || profile.username}
|
||
</h1>
|
||
<p className="text-slate-600 dark:text-slate-400 mb-1">@{profile.username}</p>
|
||
<p className="text-slate-500 dark:text-slate-500 text-sm mb-4">{profile.email}</p>
|
||
<div className="flex flex-wrap gap-4 text-sm">
|
||
<div>
|
||
<span className="text-slate-500 dark:text-slate-400">部门:</span>
|
||
<span className="text-slate-700 dark:text-slate-300">{profile.department || '未设置'}</span>
|
||
</div>
|
||
{profile.phone && (
|
||
<div>
|
||
<span className="text-slate-500 dark:text-slate-400">手机:</span>
|
||
<span className="text-slate-700 dark:text-slate-300">{profile.phone}</span>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<span className="text-slate-500 dark:text-slate-400">注册时间:</span>
|
||
<span className="text-slate-700 dark:text-slate-300">
|
||
{new Date(profile.createdAt).toLocaleDateString('zh-CN')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 我的素材 */}
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-4">我的素材</h2>
|
||
{materials.length === 0 ? (
|
||
<p className="text-slate-500 dark:text-slate-400 text-center py-8">还没有上传过素材</p>
|
||
) : (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||
{materials.map((material) => (
|
||
<div
|
||
key={material.id}
|
||
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||
onClick={async () => {
|
||
// 获取完整的素材信息
|
||
try {
|
||
const response = await fetch(`/api/materials/${material.id}`)
|
||
if (response.ok) {
|
||
const fullMaterial = await response.json()
|
||
|
||
// 解析 displayImages(可能是字符串或数组)
|
||
let displayImagesArray: string[] = []
|
||
const parseDisplayImages = (value: any): string[] => {
|
||
if (!value) return []
|
||
if (Array.isArray(value)) {
|
||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||
}
|
||
if (typeof value === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||
}
|
||
} catch {
|
||
return value.split(',').filter((s: string) => s.trim())
|
||
}
|
||
}
|
||
return []
|
||
}
|
||
displayImagesArray = parseDisplayImages(fullMaterial.displayImages) || parseDisplayImages(material.displayImages)
|
||
|
||
// 确保所有必需字段都存在
|
||
const safeMaterial: Material = {
|
||
id: fullMaterial.id || material.id,
|
||
userId: fullMaterial.userId || material.userId || '',
|
||
productModel: fullMaterial.productModel || material.productModel,
|
||
previewUrl: fullMaterial.previewUrl || material.previewUrl,
|
||
displayImages: displayImagesArray.length > 0 ? displayImagesArray : material.displayImages,
|
||
materialType: fullMaterial.materialType || material.materialType,
|
||
materialKind: fullMaterial.materialKind || material.materialKind,
|
||
categoryName: fullMaterial.categoryName || material.categoryName || '',
|
||
sceneName: fullMaterial.sceneName || material.sceneName || '',
|
||
renderer: fullMaterial.renderer || material.renderer,
|
||
authorName: fullMaterial.authorName || material.authorName,
|
||
downloadCount: fullMaterial.downloadCount ?? material.downloadCount ?? 0,
|
||
likeCount: fullMaterial.likeCount ?? material.likeCount ?? 0,
|
||
createdAt: fullMaterial.createdAt || material.createdAt,
|
||
isPremium: fullMaterial.isPremium ?? material.isPremium ?? false,
|
||
isFavorited: fullMaterial.isFavorited ?? material.isFavorited ?? false,
|
||
isLiked: fullMaterial.isLiked ?? material.isLiked ?? false,
|
||
zipFileName: fullMaterial.zipFileName || material.zipFileName,
|
||
zipFileSize: fullMaterial.zipFileSize || material.zipFileSize,
|
||
videoCoverUrl: fullMaterial.videoCoverUrl ?? material.videoCoverUrl,
|
||
videoPreview540Url: fullMaterial.videoPreview540Url ?? material.videoPreview540Url,
|
||
videoPreviewClipUrl: fullMaterial.videoPreviewClipUrl ?? material.videoPreviewClipUrl,
|
||
videoProcessingStatus: fullMaterial.videoProcessingStatus ?? material.videoProcessingStatus,
|
||
videoDuration: fullMaterial.videoDuration ?? material.videoDuration,
|
||
user: {
|
||
username: fullMaterial.user?.username || material.user?.username,
|
||
avatar: fullMaterial.user?.avatar || material.user?.avatar || null,
|
||
},
|
||
rankingBadges: fullMaterial.rankingBadges || material.rankingBadges,
|
||
}
|
||
setPreviewMaterial(safeMaterial)
|
||
} else {
|
||
setPreviewMaterial(material)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch material details:', error)
|
||
setPreviewMaterial(material)
|
||
}
|
||
}}
|
||
onDoubleClick={() => router.push(`/share/${material.id}`)}
|
||
title="单击预览,双击查看详情"
|
||
>
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
||
<p className="text-white text-xs font-medium truncate">{material.productModel}</p>
|
||
<div className="flex items-center gap-2 text-[10px] text-white/80 mt-1">
|
||
<span>下载 {material.downloadCount}</span>
|
||
<span>点赞 {material.likeCount}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* 我的点赞 */}
|
||
<div className={cn(styles.cardStyles.base, 'mt-6')}>
|
||
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-4">我的点赞</h2>
|
||
{favoriteMaterials.length === 0 ? (
|
||
<p className="text-slate-500 dark:text-slate-400 text-center py-8">还没有点赞素材</p>
|
||
) : (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||
{favoriteMaterials.map((material) => (
|
||
<div
|
||
key={material.id}
|
||
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
|
||
onClick={async () => {
|
||
// 获取完整的素材信息
|
||
try {
|
||
const response = await fetch(`/api/materials/${material.id}`)
|
||
if (response.ok) {
|
||
const fullMaterial = await response.json()
|
||
|
||
// 解析 displayImages(可能是字符串或数组)
|
||
let displayImagesArray: string[] = []
|
||
const parseDisplayImages = (value: any): string[] => {
|
||
if (!value) return []
|
||
if (Array.isArray(value)) {
|
||
return value.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||
}
|
||
if (typeof value === 'string') {
|
||
try {
|
||
const parsed = JSON.parse(value)
|
||
if (Array.isArray(parsed)) {
|
||
return parsed.filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
|
||
}
|
||
} catch {
|
||
return value.split(',').filter((s: string) => s.trim())
|
||
}
|
||
}
|
||
return []
|
||
}
|
||
displayImagesArray = parseDisplayImages(fullMaterial.displayImages) || parseDisplayImages(material.displayImages)
|
||
|
||
// 确保所有必需字段都存在
|
||
const safeMaterial: Material = {
|
||
id: fullMaterial.id || material.id,
|
||
userId: fullMaterial.userId || material.userId || '',
|
||
productModel: fullMaterial.productModel || material.productModel,
|
||
previewUrl: fullMaterial.previewUrl || material.previewUrl,
|
||
displayImages: displayImagesArray.length > 0 ? displayImagesArray : material.displayImages,
|
||
materialType: fullMaterial.materialType || material.materialType,
|
||
materialKind: fullMaterial.materialKind || material.materialKind,
|
||
categoryName: fullMaterial.categoryName || material.categoryName || '',
|
||
sceneName: fullMaterial.sceneName || material.sceneName || '',
|
||
renderer: fullMaterial.renderer || material.renderer,
|
||
authorName: fullMaterial.authorName || material.authorName,
|
||
downloadCount: fullMaterial.downloadCount ?? material.downloadCount ?? 0,
|
||
likeCount: fullMaterial.likeCount ?? material.likeCount ?? 0,
|
||
createdAt: fullMaterial.createdAt || material.createdAt,
|
||
isPremium: fullMaterial.isPremium ?? material.isPremium ?? false,
|
||
isFavorited: fullMaterial.isFavorited ?? material.isFavorited ?? false,
|
||
isLiked: fullMaterial.isLiked ?? material.isLiked ?? false,
|
||
zipFileName: fullMaterial.zipFileName || material.zipFileName,
|
||
zipFileSize: fullMaterial.zipFileSize || material.zipFileSize,
|
||
videoCoverUrl: fullMaterial.videoCoverUrl ?? material.videoCoverUrl,
|
||
videoPreview540Url: fullMaterial.videoPreview540Url ?? material.videoPreview540Url,
|
||
videoPreviewClipUrl: fullMaterial.videoPreviewClipUrl ?? material.videoPreviewClipUrl,
|
||
videoProcessingStatus: fullMaterial.videoProcessingStatus ?? material.videoProcessingStatus,
|
||
videoDuration: fullMaterial.videoDuration ?? material.videoDuration,
|
||
user: {
|
||
username: fullMaterial.user?.username || material.user?.username,
|
||
avatar: fullMaterial.user?.avatar || material.user?.avatar || null,
|
||
},
|
||
rankingBadges: fullMaterial.rankingBadges || material.rankingBadges,
|
||
}
|
||
setPreviewMaterial(safeMaterial)
|
||
} else {
|
||
setPreviewMaterial(material)
|
||
}
|
||
} catch (error) {
|
||
console.error('Failed to fetch material details:', error)
|
||
setPreviewMaterial(material)
|
||
}
|
||
}}
|
||
onDoubleClick={() => router.push(`/share/${material.id}`)}
|
||
title="单击预览,双击查看详情"
|
||
>
|
||
<Image
|
||
src={material.previewUrl}
|
||
alt={material.productModel}
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2">
|
||
<p className="text-white text-xs font-medium truncate">{material.productModel}</p>
|
||
<div className="flex items-center gap-2 text-[10px] text-white/80 mt-1">
|
||
<span>下载 {material.downloadCount}</span>
|
||
<span>点赞 {material.likeCount}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 头像上传确认弹窗 */}
|
||
<Modal
|
||
isOpen={showAvatarModal}
|
||
onClose={() => {
|
||
setShowAvatarModal(false)
|
||
if (avatarPreview) {
|
||
URL.revokeObjectURL(avatarPreview)
|
||
}
|
||
setAvatarFile(null)
|
||
setAvatarPreview('')
|
||
}}
|
||
size="small"
|
||
title="上传头像"
|
||
>
|
||
<div className="space-y-4">
|
||
{avatarPreview && (
|
||
<div className="flex justify-center">
|
||
<div className="relative w-32 h-32 rounded-full overflow-hidden border-4 border-slate-200 dark:border-slate-700">
|
||
<Image
|
||
src={avatarPreview}
|
||
alt="Avatar preview"
|
||
fill
|
||
unoptimized
|
||
className="object-cover"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="flex gap-3 justify-end">
|
||
<Button
|
||
variant="secondary"
|
||
onClick={() => {
|
||
setShowAvatarModal(false)
|
||
if (avatarPreview) {
|
||
URL.revokeObjectURL(avatarPreview)
|
||
}
|
||
setAvatarFile(null)
|
||
setAvatarPreview('')
|
||
}}
|
||
>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
onClick={handleAvatarUpload}
|
||
disabled={uploading}
|
||
loading={uploading}
|
||
>
|
||
确认上传
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
<MaterialPreviewModal
|
||
isOpen={!!previewMaterial}
|
||
onClose={() => setPreviewMaterial(null)}
|
||
material={previewMaterial}
|
||
onDownload={previewMaterial ? () => handleDownload(previewMaterial.id) : undefined}
|
||
actionSection={
|
||
previewMaterial ? (
|
||
<MaterialActionButtons
|
||
layout="modal"
|
||
material={previewMaterial}
|
||
themeColor={session?.user ? (session.user as any).themeColor || '#6366f1' : '#6366f1'}
|
||
onDownload={() => handleDownload(previewMaterial.id)}
|
||
isDownloading={downloadingIds.has(previewMaterial.id)}
|
||
downloadProgress={downloadProgresses[previewMaterial.id] || 0}
|
||
inlineLikeButton={false}
|
||
/>
|
||
) : undefined
|
||
}
|
||
/>
|
||
</div>
|
||
)
|
||
}
|
||
|