290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useSession } from 'next-auth/react'
|
|
import { useParams, useRouter } from 'next/navigation'
|
|
import Image from 'next/image'
|
|
import Link from 'next/link'
|
|
import Header from '@/components/Header'
|
|
import { useThemeStyles, cn } from '@/lib/ui-styles'
|
|
|
|
// 内联SVG图标组件
|
|
const UserIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
</svg>
|
|
)
|
|
|
|
const ArrowLeftIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
)
|
|
|
|
const DownloadIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} 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>
|
|
)
|
|
|
|
const HeartIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} 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>
|
|
)
|
|
|
|
const ChevronLeftIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
)
|
|
|
|
const ChevronRightIcon = ({ className }: { className?: string }) => (
|
|
<svg className={className} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
)
|
|
|
|
interface Material {
|
|
id: string
|
|
productModel: string
|
|
previewUrl: string
|
|
materialType: string | null
|
|
categoryName: string
|
|
sceneName: string
|
|
downloadCount: number
|
|
likeCount: number
|
|
createdAt: string
|
|
user?: {
|
|
username: string
|
|
avatar: string | null
|
|
}
|
|
}
|
|
|
|
interface AuthorMaterialsResponse {
|
|
materials: Material[]
|
|
pagination: {
|
|
page: number
|
|
pageSize: number
|
|
total: number
|
|
totalPages: number
|
|
}
|
|
author: {
|
|
name: string
|
|
totalMaterials: number
|
|
totalDownloads: number
|
|
totalLikes: number
|
|
}
|
|
}
|
|
|
|
export default function AuthorPage() {
|
|
const { data: session, status } = useSession()
|
|
const params = useParams()
|
|
const router = useRouter()
|
|
const styles = useThemeStyles()
|
|
const authorName = decodeURIComponent(params.authorName as string)
|
|
|
|
const [materials, setMaterials] = useState<Material[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [page, setPage] = useState(1)
|
|
const [totalPages, setTotalPages] = useState(1)
|
|
const [authorStats, setAuthorStats] = useState({
|
|
name: authorName,
|
|
totalMaterials: 0,
|
|
totalDownloads: 0,
|
|
totalLikes: 0,
|
|
avatar: null as string | null,
|
|
})
|
|
const pageSize = 20
|
|
|
|
const fetchMaterials = useCallback(async () => {
|
|
if (status !== 'authenticated') return
|
|
|
|
setLoading(true)
|
|
try {
|
|
const res = await fetch(
|
|
`/api/materials?authorName=${encodeURIComponent(authorName)}&page=${page}&pageSize=${pageSize}`
|
|
)
|
|
if (res.ok) {
|
|
const data = await res.json()
|
|
setMaterials(data.materials || [])
|
|
setTotalPages(data.pagination?.totalPages || 1)
|
|
// 计算作者统计
|
|
if (data.materials?.length > 0) {
|
|
const totalDownloads = data.materials.reduce(
|
|
(sum: number, m: Material) => sum + m.downloadCount,
|
|
0
|
|
)
|
|
const totalLikes = data.materials.reduce(
|
|
(sum: number, m: Material) => sum + m.likeCount,
|
|
0
|
|
)
|
|
// 获取作者头像(从第一个素材的用户信息中获取)
|
|
const authorAvatar = data.materials[0]?.user?.avatar || null
|
|
setAuthorStats({
|
|
name: authorName,
|
|
totalMaterials: data.pagination?.total || data.materials.length,
|
|
totalDownloads,
|
|
totalLikes,
|
|
avatar: authorAvatar,
|
|
})
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch author materials:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [authorName, page, status])
|
|
|
|
useEffect(() => {
|
|
if (status === 'unauthenticated') {
|
|
router.push('/login')
|
|
return
|
|
}
|
|
|
|
if (status === 'authenticated') {
|
|
fetchMaterials()
|
|
}
|
|
}, [fetchMaterials, router, status])
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
|
<Header session={session} />
|
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
{/* 返回按钮 */}
|
|
<button
|
|
onClick={() => router.back()}
|
|
className="flex items-center gap-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white mb-6 transition-colors"
|
|
>
|
|
<ArrowLeftIcon className="w-5 h-5" />
|
|
返回
|
|
</button>
|
|
|
|
{/* 作者信息卡片 */}
|
|
<div className={cn(styles.cardStyles.base, 'mb-8')}>
|
|
<div className="flex items-center gap-6">
|
|
{authorStats.avatar ? (
|
|
<div className="w-20 h-20 rounded-full overflow-hidden">
|
|
<Image
|
|
src={authorStats.avatar}
|
|
alt={authorStats.name}
|
|
width={80}
|
|
height={80}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="w-20 h-20 rounded-full border border-slate-200 bg-slate-100 dark:border-slate-700 dark:bg-slate-800 flex items-center justify-center">
|
|
<UserIcon className="w-10 h-10 text-slate-500 dark:text-slate-300" />
|
|
</div>
|
|
)}
|
|
<div className="flex-1">
|
|
<h1 className="text-2xl font-bold text-slate-900 dark:text-white mb-2">
|
|
{authorStats.name}
|
|
</h1>
|
|
<div className="flex flex-wrap gap-6 text-sm text-slate-600 dark:text-slate-400">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-slate-900 dark:text-white">
|
|
{authorStats.totalMaterials}
|
|
</span>
|
|
<span>作品</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<DownloadIcon className="w-4 h-4" />
|
|
<span className="font-medium text-slate-900 dark:text-white">
|
|
{authorStats.totalDownloads}
|
|
</span>
|
|
<span>下载</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<HeartIcon className="w-4 h-4" />
|
|
<span className="font-medium text-slate-900 dark:text-white">
|
|
{authorStats.totalLikes}
|
|
</span>
|
|
<span>点赞</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 作品列表 */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-16">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
|
</div>
|
|
) : materials.length === 0 ? (
|
|
<div className="text-center py-16">
|
|
<UserIcon className="w-16 h-16 mx-auto text-slate-300 dark:text-slate-600 mb-4" />
|
|
<p className="text-slate-500 dark:text-slate-400">该作者暂无作品</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
|
{materials.map((material) => (
|
|
<Link
|
|
key={material.id}
|
|
href={`/share/${material.id}`}
|
|
className="group relative bg-white dark:bg-slate-800 rounded-xl overflow-hidden shadow-sm hover:shadow-md transition-shadow"
|
|
>
|
|
<div className="aspect-[4/3] relative bg-slate-100 dark:bg-slate-700">
|
|
<Image
|
|
src={material.previewUrl}
|
|
alt={material.productModel}
|
|
fill
|
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
|
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
|
|
/>
|
|
</div>
|
|
<div className="p-3">
|
|
<h3 className="text-sm font-medium text-slate-900 dark:text-white truncate">
|
|
{material.productModel}
|
|
</h3>
|
|
<div className="flex items-center justify-between mt-2 text-xs text-slate-500 dark:text-slate-400">
|
|
<span>{material.materialType || material.categoryName}</span>
|
|
<div className="flex items-center gap-3">
|
|
<span className="flex items-center gap-1">
|
|
<DownloadIcon className="w-3 h-3" />
|
|
{material.downloadCount}
|
|
</span>
|
|
<span className="flex items-center gap-1">
|
|
<HeartIcon className="w-3 h-3" />
|
|
{material.likeCount}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
|
|
{/* 分页 */}
|
|
{totalPages > 1 && (
|
|
<div className="flex items-center justify-center gap-2 mt-8">
|
|
<button
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1}
|
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronLeftIcon className="w-5 h-5" />
|
|
</button>
|
|
<span className="text-sm text-slate-600 dark:text-slate-400">
|
|
{page} / {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages}
|
|
className="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
<ChevronRightIcon className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|