v1
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { AddToCartButton } from '@/components/AddToCartButton'
|
||||
import { ArrowLeft, Package, Star, Shield, Truck, RotateCcw } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface ComponentDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string
|
||||
}>
|
||||
}
|
||||
|
||||
export default async function ComponentDetailPage({ params }: ComponentDetailPageProps) {
|
||||
const component = await prisma.component.findUnique({
|
||||
where: { id: (await params).id },
|
||||
include: {
|
||||
componentType: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!component) {
|
||||
notFound()
|
||||
}
|
||||
|
||||
const specifications = component.specifications ? JSON.parse(component.specifications) : {}
|
||||
|
||||
// 获取同类型的其他产品推荐
|
||||
const relatedComponents = await prisma.component.findMany({
|
||||
where: {
|
||||
componentTypeId: component.componentTypeId,
|
||||
id: { not: component.id }
|
||||
},
|
||||
take: 4,
|
||||
orderBy: {
|
||||
createdAt: 'desc'
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center space-x-2 text-sm text-gray-500 mb-8">
|
||||
<Link href="/" className="hover:text-blue-600">首页</Link>
|
||||
<span>/</span>
|
||||
<Link href="/components" className="hover:text-blue-600">配件商城</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/components?type=${component.componentType.name}`} className="hover:text-blue-600">
|
||||
{component.componentType.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-gray-900">{component.name}</span>
|
||||
</nav>
|
||||
|
||||
{/* Back Button */}
|
||||
<Link
|
||||
href="/components"
|
||||
className="inline-flex items-center text-blue-600 hover:text-blue-800 mb-6"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
返回商品列表
|
||||
</Link>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
|
||||
{/* Product Image */}
|
||||
<div className="space-y-4">
|
||||
<div className="aspect-square bg-white rounded-lg border border-gray-200 flex items-center justify-center">
|
||||
{component.imageUrl ? (
|
||||
<img loading='lazy'
|
||||
src={component.imageUrl}
|
||||
alt={component.name}
|
||||
className="max-w-full max-h-full object-contain grow"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center text-gray-400">
|
||||
<Package className="h-24 w-24 mx-auto mb-4" />
|
||||
<p>暂无商品图片</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 text-sm text-gray-500 mb-2">
|
||||
<span className="bg-blue-100 text-blue-800 px-2 py-1 rounded">
|
||||
{component.componentType.name}
|
||||
</span>
|
||||
<span className="bg-gray-100 text-gray-800 px-2 py-1 rounded">
|
||||
{component.brand}
|
||||
</span>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">
|
||||
{component.name}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
<div className="flex items-center">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<Star
|
||||
key={i}
|
||||
className={`h-5 w-5 ${i < 4 ? 'text-yellow-400 fill-current' : 'text-gray-300'}`}
|
||||
/>
|
||||
))}
|
||||
<span className="ml-2 text-sm text-gray-600">(128 评价)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="border-t border-b border-gray-200 py-6">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-3xl font-bold text-red-600">
|
||||
¥{component.price}
|
||||
</span>
|
||||
<span className="text-lg text-gray-500 line-through">
|
||||
¥{Math.round(component.price * 1.2)}
|
||||
</span>
|
||||
<span className="bg-red-100 text-red-800 text-sm px-2 py-1 rounded">
|
||||
8折
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">价格含税,全国包邮</p>
|
||||
</div>
|
||||
|
||||
{/* Stock Status */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-600">库存状态:</span>
|
||||
{component.stock > 0 ? (
|
||||
<span className="text-green-600 font-medium">
|
||||
有货 ({component.stock} 件)
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-red-600 font-medium">缺货</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add to Cart */}
|
||||
<div className="space-y-4">
|
||||
<AddToCartButton
|
||||
componentId={component.id}
|
||||
disabled={component.stock === 0}
|
||||
className="w-full"
|
||||
/>
|
||||
<button className="w-full border border-gray-300 text-gray-700 py-3 px-6 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
添加到装机配置
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Service Promises */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-6 border-t border-gray-200">
|
||||
<div className="text-center">
|
||||
<Truck className="h-8 w-8 text-blue-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-600">全国包邮</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Shield className="h-8 w-8 text-green-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-600">正品保障</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<RotateCcw className="h-8 w-8 text-orange-600 mx-auto mb-2" />
|
||||
<p className="text-xs text-gray-600">7天无理由退换</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Details */}
|
||||
<div className="mt-12 grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Description */}
|
||||
<div className="lg:col-span-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">商品详情</h2>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-900 mb-4">产品描述</h3>
|
||||
<p className="text-gray-700 leading-relaxed mb-6">
|
||||
{component.description || '暂无详细描述'}
|
||||
</p>
|
||||
|
||||
{Object.keys(specifications).length > 0 && (
|
||||
<>
|
||||
<h3 className="font-semibold text-gray-900 mb-4">技术规格</h3>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(specifications).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between py-2 border-b border-gray-100">
|
||||
<span className="text-gray-600">{key}:</span>
|
||||
<span className="font-medium text-gray-900">{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Product Info */}
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">产品信息</h2>
|
||||
<div className="bg-white rounded-lg p-6 shadow-sm space-y-4">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">品牌:</span>
|
||||
<span className="font-medium">{component.brand}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">型号:</span>
|
||||
<span className="font-medium">{component.model}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">分类:</span>
|
||||
<span className="font-medium">{component.componentType.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600">库存:</span>
|
||||
<span className="font-medium">{component.stock} 件</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Related Products */}
|
||||
{relatedComponents.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">相关推荐</h2>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{relatedComponents.map((item) => (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/components/${item.id}`}
|
||||
className="bg-white rounded-lg shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="aspect-square bg-gray-100 rounded-lg mb-4 flex items-center justify-center">
|
||||
{item.imageUrl ? (
|
||||
<img loading='lazy'
|
||||
src={item.imageUrl}
|
||||
alt={item.name}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<Package className="h-12 w-12 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<h3 className="font-medium text-gray-900 mb-2 line-clamp-2">
|
||||
{item.name}
|
||||
</h3>
|
||||
<p className="text-lg font-bold text-red-600">¥{item.price}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { ComponentCard } from '@/components/ComponentCard'
|
||||
import { Search, Filter } from 'lucide-react'
|
||||
|
||||
interface Component {
|
||||
id: string
|
||||
name: string
|
||||
brand: string
|
||||
model: string
|
||||
price: number
|
||||
description?: string
|
||||
imageUrl?: string
|
||||
stock: number
|
||||
componentType: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
interface ComponentType {
|
||||
id: string
|
||||
name: string
|
||||
_count: {
|
||||
components: number
|
||||
}
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
|
||||
export default function ComponentsPage() {
|
||||
const [components, setComponents] = useState<Component[]>([])
|
||||
const [componentTypes, setComponentTypes] = useState<ComponentType[]>([])
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 12,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
})
|
||||
const [filters, setFilters] = useState({
|
||||
search: '',
|
||||
type: '',
|
||||
brand: '',
|
||||
minPrice: '',
|
||||
maxPrice: ''
|
||||
})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponentTypes()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchComponents()
|
||||
}, [filters, pagination.page])
|
||||
|
||||
const fetchComponentTypes = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/component-types')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setComponentTypes(data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch component types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchComponents = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const searchParams = new URLSearchParams()
|
||||
if (filters.search) searchParams.set('search', filters.search)
|
||||
if (filters.type) searchParams.set('type', filters.type)
|
||||
if (filters.brand) searchParams.set('brand', filters.brand)
|
||||
if (filters.minPrice) searchParams.set('minPrice', filters.minPrice)
|
||||
if (filters.maxPrice) searchParams.set('maxPrice', filters.maxPrice)
|
||||
searchParams.set('page', pagination.page.toString())
|
||||
searchParams.set('limit', pagination.limit.toString())
|
||||
|
||||
const response = await fetch(`/api/components?${searchParams}`)
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
setComponents(data.components)
|
||||
setPagination(data.pagination)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch components:', error)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
setFilters({ ...filters, [key]: value })
|
||||
setPagination({ ...pagination, page: 1 })
|
||||
}
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPagination({ ...pagination, page: newPage })
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({
|
||||
search: '',
|
||||
type: '',
|
||||
brand: '',
|
||||
minPrice: '',
|
||||
maxPrice: ''
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-4">电脑配件商城</h1>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索配件名称、品牌、型号..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
<Filter className="h-5 w-5" />
|
||||
筛选
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-6 rounded-lg shadow-sm border mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
配件类型
|
||||
</label>
|
||||
<select
|
||||
value={filters.type}
|
||||
onChange={(e) => handleFilterChange('type', e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{componentTypes.map((type) => (
|
||||
<option key={type.id} value={type.id}>
|
||||
{type.name} ({type._count.components})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
品牌
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="品牌名称"
|
||||
value={filters.brand}
|
||||
onChange={(e) => handleFilterChange('brand', e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
最低价格
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="0"
|
||||
value={filters.minPrice}
|
||||
onChange={(e) => handleFilterChange('minPrice', e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
最高价格
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="10000"
|
||||
value={filters.maxPrice}
|
||||
onChange={(e) => handleFilterChange('maxPrice', e.target.value)}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
重置筛选
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600">
|
||||
共找到 {pagination.total} 个配件
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading */}
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center py-12">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Components Grid */}
|
||||
{components.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6 mb-8">
|
||||
{components.map((component) => (
|
||||
<ComponentCard key={component.id} component={component} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 text-lg">没有找到符合条件的配件</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex justify-center items-center space-x-2">
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page - 1)}
|
||||
disabled={!pagination.hasPrev}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
|
||||
<div className="flex space-x-1">
|
||||
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={`px-3 py-2 border rounded-md ${
|
||||
page === pagination.page
|
||||
? 'bg-blue-600 text-white border-blue-600'
|
||||
: 'border-gray-300 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handlePageChange(pagination.page + 1)}
|
||||
disabled={!pagination.hasNext}
|
||||
className="px-3 py-2 border border-gray-300 rounded-md disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-50"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user