flnxi-Wu/app/profile/page.tsx

729 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

/**
* 个人中心页面
*
* 用户个人信息管理页面
* 包含用户信息展示、头像设置、上传的素材、下载记录等
* 使用三层架构样式系统,支持深浅色主题
*
* @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, update } = 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 [browseHistory, setBrowseHistory] = 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()
fetchBrowseHistory()
}
}, [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 fetchBrowseHistory = async () => {
try {
const res = await fetch('/api/user/history?pageSize=8')
if (res.ok) {
const data = await res.json()
// 转换为Material格式
const historyMaterials = data.history.map((item: any) => ({
id: item.material.id,
productModel: item.material.title,
previewUrl: item.material.thumbnailUrl,
materialType: item.material.materialType,
downloadCount: item.material.viewCount || 0,
likeCount: 0,
createdAt: item.viewedAt,
}))
setBrowseHistory(historyMaterials)
}
} catch (error) {
console.error('Failed to fetch browse history:', 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 会从数据库获取最新头像
// 使用 update() 方法触发 trigger='update',立即刷新 token
await update()
// 刷新页面确保所有组件使用最新的 session
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 className={cn(styles.cardStyles.base, 'mt-6')}>
<h2 className="text-xl font-bold text-slate-900 dark:text-white mb-4">浏览历史</h2>
{browseHistory.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">
{browseHistory.map((material) => (
<div
key={material.id}
className="relative aspect-[3/4] rounded-2xl overflow-hidden cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => 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.materialType || '未分类'}</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>
)
}