This commit is contained in:
2025-06-22 11:34:32 +08:00
parent 142992a6c9
commit 7d139b22db
55 changed files with 11035 additions and 105 deletions
+103
View File
@@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
import { ShoppingCart, Plus, Minus } from 'lucide-react'
interface AddToCartButtonProps {
componentId: string
disabled?: boolean
className?: string
}
export function AddToCartButton({ componentId, disabled = false, className = '' }: AddToCartButtonProps) {
const [quantity, setQuantity] = useState(1)
const [isLoading, setIsLoading] = useState(false)
const handleAddToCart = async () => {
const token = localStorage.getItem('token')
if (!token) {
alert('请先登录')
return
}
setIsLoading(true)
try {
const response = await fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
componentId,
quantity
})
})
if (response.ok) {
// 触发购物车更新事件
window.dispatchEvent(new Event('cart-updated'))
alert('已添加到购物车!')
} else if (response.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
alert('登录已过期,请重新登录')
window.location.href = '/login'
} else {
const data = await response.json()
alert(data.message || '添加失败')
}
} catch (error) {
console.error('添加到购物车失败:', error)
alert('添加失败,请重试')
} finally {
setIsLoading(false)
}
}
return (
<div className={`space-y-4 ${className}`}>
{/* Quantity Selector */}
<div className="flex items-center space-x-4">
<span className="text-sm text-gray-600">数量:</span>
<div className="flex items-center border border-gray-300 rounded-lg">
<button
type="button"
onClick={() => setQuantity(Math.max(1, quantity - 1))}
disabled={quantity <= 1 || disabled}
className="p-2 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Minus className="h-4 w-4" />
</button>
<span className="px-4 py-2 text-center min-w-[50px]">{quantity}</span>
<button
type="button"
onClick={() => setQuantity(quantity + 1)}
disabled={disabled}
className="p-2 hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-4 w-4" />
</button>
</div>
</div>
{/* Add to Cart Button */}
<button
onClick={handleAddToCart}
disabled={disabled || isLoading}
className={`
flex items-center justify-center space-x-2 w-full py-3 px-6 rounded-lg font-medium transition-colors
${disabled
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
${isLoading ? 'opacity-50 cursor-not-allowed' : ''}
`}
>
<ShoppingCart className="h-5 w-5" />
<span>
{isLoading ? '添加中...' : disabled ? '缺货' : '加入购物车'}
</span>
</button>
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
'use client'
import Link from 'next/link'
import Image from 'next/image'
import { useState } from 'react'
import { ShoppingCart, Check } from 'lucide-react'
interface Component {
id: string
name: string
brand: string
model: string
price: number
description?: string | null
imageUrl?: string | null
stock: number
componentType?: {
id: string
name: string
}
}
interface ComponentCardProps {
component: Component
}
export function ComponentCard({ component }: ComponentCardProps) {
const [isAdding, setIsAdding] = useState(false)
const [isAdded, setIsAdded] = useState(false)
const addToCart = async (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (component.stock <= 0 || isAdding) return
const token = localStorage.getItem('token')
if (!token) {
alert('请先登录')
return
}
setIsAdding(true)
try {
const response = await fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
componentId: component.id,
quantity: 1
})
})
if (response.ok) {
setIsAdded(true)
setTimeout(() => setIsAdded(false), 2000)
} else if (response.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
alert('登录已过期,请重新登录')
window.location.href = '/login'
} else {
const data = await response.json()
alert(data.message || '添加失败')
}
} catch (error) {
console.error('添加到购物车失败:', error)
alert('添加失败')
} finally {
setIsAdding(false)
}
}
return (
<div className="bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-300 overflow-hidden">
<Link href={`/components/${component.id}`}>
<div className="relative h-48 bg-gray-100">
{component.imageUrl ? (
<Image
src={component.imageUrl}
alt={component.name}
fill
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
className="object-cover"
/>
) : (
<div className="flex items-center justify-center h-full">
<span className="text-gray-400 text-4xl">📦</span>
</div>
)}
</div>
</Link>
<div className="p-4">
<div className="text-sm text-gray-500 mb-1">{component.brand}</div>
<Link href={`/components/${component.id}`}>
<h3 className="font-semibold text-lg mb-2 line-clamp-2 hover:text-blue-600 transition-colors">
{component.name}
</h3>
</Link>
{component.description && (
<p className="text-gray-600 text-sm mb-3 line-clamp-2">
{component.description}
</p>
)}
<div className="flex justify-between items-center mb-3">
<span className="text-2xl font-bold text-blue-600">
¥{component.price.toLocaleString()}
</span>
<span className={`text-sm px-2 py-1 rounded ${component.stock > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{component.stock > 0 ? `库存 ${component.stock}` : '缺货'}
</span>
</div>
<button
onClick={addToCart}
disabled={component.stock <= 0 || isAdding}
className={`w-full py-2 px-4 rounded-lg font-medium transition-colors flex items-center justify-center ${component.stock <= 0
? 'bg-gray-100 text-gray-400 cursor-not-allowed'
: isAdded
? 'bg-green-600 text-white'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isAdding ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
) : isAdded ? (
<>
<Check className="h-4 w-4 mr-2" />
已添加
</>
) : (
<>
<ShoppingCart className="h-4 w-4 mr-2" />
{component.stock <= 0 ? '缺货' : '加入购物车'}
</>
)}
</button>
</div>
</div>
)
}
+251
View File
@@ -0,0 +1,251 @@
'use client'
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import { User, ShoppingCart, Menu, X } from 'lucide-react'
interface User {
id: string
email: string
username: string
name?: string
isAdmin: boolean
}
export function Navbar() {
const [user, setUser] = useState<User | null>(null)
const [isMenuOpen, setIsMenuOpen] = useState(false)
const pathname = usePathname()
const router = useRouter()
useEffect(() => {
// 检查用户登录状态
const checkUserStatus = () => {
const token = localStorage.getItem('token')
if (token) {
const userData = localStorage.getItem('user')
if (userData) {
setUser(JSON.parse(userData))
}
} else {
setUser(null)
}
}
checkUserStatus()
// 监听localStorage变化
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'user' || e.key === 'token') {
checkUserStatus()
}
}
// 监听自定义用户更新事件
const handleUserUpdate = () => {
checkUserStatus()
}
window.addEventListener('storage', handleStorageChange)
window.addEventListener('user-updated', handleUserUpdate)
return () => {
window.removeEventListener('storage', handleStorageChange)
window.removeEventListener('user-updated', handleUserUpdate)
}
}, [])
const handleLogout = () => {
localStorage.removeItem('token')
localStorage.removeItem('user')
setUser(null)
// 触发用户更新事件
window.dispatchEvent(new Event('user-updated'))
router.push('/')
}
// 检查当前路径是否匹配
const isActive = (path: string) => {
if (path === '/') {
return pathname === '/'
}
return pathname.startsWith(path)
}
// 导航链接样式
const getLinkStyle = (path: string) => {
return isActive(path)
? 'text-blue-600 font-medium'
: 'text-gray-700 hover:text-blue-600'
}
return (
<nav className="bg-white shadow-md sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link href="/" className="text-2xl font-bold text-blue-600">
PC DIY商城
</Link> {/* Desktop Navigation */}
<div className="hidden md:flex space-x-8">
<Link href="/" className={getLinkStyle('/')}>
首页
</Link>
<Link href="/components" className={getLinkStyle('/components')}>
配件商城
</Link>
<Link href="/build" className={getLinkStyle('/build')}>
装机配置
</Link>
</div>
{/* User Actions */}
<div className="hidden md:flex items-center space-x-4">
{user ? (
<div className="flex items-center space-x-4">
<Link href="/cart" className="text-gray-700 hover:text-blue-600">
<ShoppingCart className="h-6 w-6" />
</Link>
<div className="relative group">
<button className="flex items-center space-x-2 text-gray-700 hover:text-blue-600">
<User className="h-6 w-6" />
<span>{user.name || user.username}</span>
</button>
<div className="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
<Link href="/profile" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
个人资料
</Link>
<Link href="/orders" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
我的订单
</Link>
{user.isAdmin && (
<Link href="/admin" className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
管理后台
</Link>
)}
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
>
退出登录
</button>
</div>
</div>
</div>
) : (
<div className="flex items-center space-x-4">
<Link
href="/login"
className="text-gray-700 hover:text-blue-600"
>
登录
</Link>
<Link
href="/register"
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700"
>
注册
</Link>
</div>
)}
</div>
{/* Mobile menu button */}
<div className="md:hidden">
<button
onClick={() => setIsMenuOpen(!isMenuOpen)}
className="text-gray-700 hover:text-blue-600"
>
{isMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</button>
</div>
</div> {/* Mobile Navigation */}
{isMenuOpen && (
<div className="md:hidden">
<div className="px-2 pt-2 pb-3 space-y-1 sm:px-3">
<Link
href="/"
className={`block px-3 py-2 ${getLinkStyle('/')}`}
onClick={() => setIsMenuOpen(false)}
>
首页
</Link>
<Link
href="/components"
className={`block px-3 py-2 ${getLinkStyle('/components')}`}
onClick={() => setIsMenuOpen(false)}
>
配件商城
</Link>
<Link
href="/build"
className={`block px-3 py-2 ${getLinkStyle('/build')}`}
onClick={() => setIsMenuOpen(false)}
>
装机配置
</Link>
{user ? (
<>
<Link
href="/cart"
className="block px-3 py-2 text-gray-700 hover:text-blue-600"
onClick={() => setIsMenuOpen(false)}
>
购物车
</Link>
<Link
href="/profile"
className="block px-3 py-2 text-gray-700 hover:text-blue-600"
onClick={() => setIsMenuOpen(false)}
>
个人资料
</Link>
<Link
href="/orders"
className="block px-3 py-2 text-gray-700 hover:text-blue-600"
onClick={() => setIsMenuOpen(false)}
>
我的订单
</Link>
{user.isAdmin && (
<Link
href="/admin"
className="block px-3 py-2 text-gray-700 hover:text-blue-600"
onClick={() => setIsMenuOpen(false)}
>
管理后台
</Link>
)}
<button
onClick={handleLogout}
className="block w-full text-left px-3 py-2 text-gray-700 hover:text-blue-600"
>
退出登录
</button>
</>
) : (
<>
<Link
href="/login"
className="block px-3 py-2 text-gray-700 hover:text-blue-600"
onClick={() => setIsMenuOpen(false)}
>
登录
</Link>
<Link
href="/register"
className="block px-3 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
onClick={() => setIsMenuOpen(false)}
>
注册
</Link>
</>
)}
</div>
</div>
)}
</div>
</nav>
)
}
+55
View File
@@ -0,0 +1,55 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
export function withAdminAuth<T extends object>(WrappedComponent: React.ComponentType<T>) {
return function AdminProtectedComponent(props: T) {
const [isLoading, setIsLoading] = useState(true)
const [isAuthorized, setIsAuthorized] = useState(false)
const router = useRouter()
useEffect(() => {
const checkAuth = () => {
const token = localStorage.getItem('token')
const user = localStorage.getItem('user')
if (!token || !user) {
router.push('/login')
return
}
try {
const userData = JSON.parse(user)
if (!userData.isAdmin) {
router.push('/')
return
}
setIsAuthorized(true)
} catch (error) {
router.push('/login')
return
} finally {
setIsLoading(false)
}
}
checkAuth()
}, [router])
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
if (!isAuthorized) {
return null
}
return <WrappedComponent {...props} />
}
}