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
+727
View File
@@ -0,0 +1,727 @@
'use client'
import { useState, useEffect } from 'react'
import { withAdminAuth } from '@/components/admin/AdminAuth'
import { Plus, Edit, Trash2, Search, Upload, Download, FileText } from 'lucide-react'
interface Component {
id: string
name: string
brand: string
model: string
price: number
description?: string
imageUrl?: string
stock: number
specifications?: string
componentType: {
id: string
name: string
}
}
interface ComponentType {
id: string
name: string
}
function AdminComponentsPage() {
const [components, setComponents] = useState<Component[]>([])
const [componentTypes, setComponentTypes] = useState<ComponentType[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [showBatchModal, setShowBatchModal] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [batchFile, setBatchFile] = useState<File | null>(null)
const [batchData, setBatchData] = useState<any[]>([])
const [batchImportLoading, setBatchImportLoading] = useState(false)
const [validationErrors, setValidationErrors] = useState<string[]>([])
const [validData, setValidData] = useState<any[]>([])
const [invalidData, setInvalidData] = useState<any[]>([])
const [formData, setFormData] = useState({
name: '',
brand: '',
model: '',
price: '',
description: '',
imageUrl: '',
stock: '',
componentTypeId: '',
specifications: ''
})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [componentsRes, typesRes] = await Promise.all([
fetch('/api/components?limit=100'),
fetch('/api/component-types')
])
if (componentsRes.ok) {
const data = await componentsRes.json()
setComponents(data.components)
}
if (typesRes.ok) {
const types = await typesRes.json()
setComponentTypes(types)
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const url = editingComponent
? `/api/components/${editingComponent.id}`
: '/api/components'
const method = editingComponent ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
})
if (response.ok) {
await loadData()
setShowModal(false)
resetForm()
} else {
const data = await response.json()
alert(data.message || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
alert('操作失败')
}
}
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个配件吗?')) return
try {
const response = await fetch(`/api/components/${id}`, {
method: 'DELETE',
})
if (response.ok) {
await loadData()
} else {
alert('删除失败')
}
} catch (error) {
console.error('删除失败:', error)
alert('删除失败')
}
}
const handleEdit = (component: Component) => {
setEditingComponent(component)
setFormData({
name: component.name,
brand: component.brand,
model: component.model,
price: component.price.toString(),
description: component.description || '',
imageUrl: component.imageUrl || '',
stock: component.stock.toString(),
componentTypeId: component.componentType.id,
specifications: component.specifications || ''
})
setShowModal(true)
}
const resetForm = () => {
setFormData({
name: '',
brand: '',
model: '',
price: '',
description: '',
imageUrl: '',
stock: '',
componentTypeId: '',
specifications: ''
})
setEditingComponent(null)
}
// 批量导入相关函数
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setBatchFile(file)
parseFile(file)
}
}
const parseFile = async (file: File) => {
const text = await file.text()
try {
let data: any[] = []
if (file.name.endsWith('.json')) {
const parsed = JSON.parse(text)
data = Array.isArray(parsed) ? parsed : [parsed]
} else if (file.name.endsWith('.csv')) {
const lines = text.split('\n')
if (lines.length < 2) {
alert('CSV文件格式错误')
return
}
const headers = lines[0].split(',').map(h => h.trim())
data = lines.slice(1)
.filter(line => line.trim())
.map(line => {
const values = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''))
const obj: any = {}
headers.forEach((header, index) => {
obj[header] = values[index] || ''
})
return obj
})
}
setBatchData(data)
validateBatchData(data)
} catch (error) {
alert('文件解析失败,请检查文件格式')
console.error('File parse error:', error)
}
}
const validateBatchData = (data: any[]) => {
const errors: string[] = []
const valid: any[] = []
const invalid: any[] = []
data.forEach((item, index) => {
const itemErrors: string[] = []
// 验证必需字段
if (!item.name || typeof item.name !== 'string' || item.name.trim() === '') {
itemErrors.push('缺少商品名称')
}
if (!item.brand || typeof item.brand !== 'string' || item.brand.trim() === '') {
itemErrors.push('缺少品牌')
}
if (!item.model || typeof item.model !== 'string' || item.model.trim() === '') {
itemErrors.push('缺少型号')
}
// 验证价格
const price = typeof item.price === 'string' ? parseFloat(item.price) : item.price
if (isNaN(price) || price < 0) {
itemErrors.push('价格必须是有效的正数')
}
// 验证库存
const stock = typeof item.stock === 'string' ? parseInt(item.stock) : item.stock
if (isNaN(stock) || stock < 0) {
itemErrors.push('库存必须是有效的非负整数')
}
// 验证配件类型
const hasTypeId = item.componentTypeId && item.componentTypeId.trim() !== ''
const hasTypeName = (item.typeName || item.type) && (item.typeName || item.type).trim() !== ''
if (!hasTypeId && !hasTypeName) {
itemErrors.push('缺少配件类型信息 (componentTypeId, typeName 或 type)')
}
if (itemErrors.length > 0) {
invalid.push({ ...item, _errors: itemErrors, _index: index + 1 })
errors.push(`第${index + 1}行: ${itemErrors.join(', ')}`)
} else {
valid.push(item)
}
})
setValidationErrors(errors)
setValidData(valid)
setInvalidData(invalid)
}
const handleBatchImport = async () => {
if (validData.length === 0) {
alert('没有有效的数据可导入')
return
}
if (invalidData.length > 0) {
const proceed = confirm(`发现${invalidData.length}行无效数据,是否只导入${validData.length}行有效数据?`)
if (!proceed) return
}
setBatchImportLoading(true)
try {
const response = await fetch('/api/components/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
components: validData
}),
})
const result = await response.json()
if (response.ok && result.success) {
const { summary, results } = result
// 显示详细的导入结果
const failedItems = results.filter((r: any) => !r.success)
let message = `导入完成!\n成功: ${summary.successful}\n失败: ${summary.failed}`
if (summary.newTypesCreated > 0) {
message += `\n新创建配件类型: ${summary.newTypesCreated}`
}
if (failedItems.length > 0 && failedItems.length <= 5) {
message += '\n\n失败项目:'
failedItems.forEach((item: any, index: number) => {
message += `\n${index + 1}. ${item.item.name || '未知'}: ${item.error}`
})
} else if (failedItems.length > 5) {
message += `\n\n失败项目过多,请检查数据格式或查看控制台详情`
console.log('批量导入失败项目:', failedItems)
}
alert(message)
if (summary.successful > 0) {
await loadData()
setShowBatchModal(false)
setBatchFile(null)
setBatchData([])
setValidData([])
setInvalidData([])
setValidationErrors([])
}
} else {
alert(`批量导入失败: ${result.message || '未知错误'}`)
console.error('Batch import error:', result)
}
} catch (error) {
alert('批量导入失败:网络或服务器错误')
console.error('Batch import error:', error)
} finally {
setBatchImportLoading(false)
}
}
const downloadTemplate = (format: 'csv' | 'json') => {
const sampleData = [
{
name: 'Intel Core i5-13400F',
brand: 'Intel',
model: 'i5-13400F',
price: 1299,
description: '10核16线程,基础频率2.5GHz,最大睿频4.6GHz',
imageUrl: '',
stock: 50,
typeName: 'CPU',
specifications: '{"cores":10,"threads":16,"baseClock":"2.5GHz","boostClock":"4.6GHz","socket":"LGA1700"}'
}
]
if (format === 'csv') {
const headers = ['name', 'brand', 'model', 'price', 'description', 'imageUrl', 'stock', 'typeName', 'specifications']
const csvContent = [
headers.join(','),
...sampleData.map(item => headers.map(header => `"${item[header as keyof typeof item] || ''}"`).join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'components_template.csv'
a.click()
URL.revokeObjectURL(url)
} else {
const jsonContent = JSON.stringify(sampleData, null, 2)
const blob = new Blob([jsonContent], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'components_template.json'
a.click()
URL.revokeObjectURL(url)
}
}
const filteredComponents = components.filter(component =>
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.model.toLowerCase().includes(searchTerm.toLowerCase())
)
if (isLoading) {
return (
<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>
)
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">配件管理</h1>
<div className="flex gap-3">
<button
onClick={() => setShowBatchModal(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Upload className="h-5 w-5" />
批量导入
</button>
<button
onClick={() => {
resetForm()
setShowModal(true)
}}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="h-5 w-5" />
添加配件
</button>
</div>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<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={searchTerm}
onChange={(e) => setSearchTerm(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>
</div>
{/* Components Table */}
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
配件信息
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
类型
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
价格
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
库存
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredComponents.map((component) => (
<tr key={component.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0">
{component.imageUrl ? (
<img loading='lazy'
className="h-10 w-10 rounded-full object-cover"
src={component.imageUrl}
alt={component.name}
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
📦
</div>
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{component.name}
</div>
<div className="text-sm text-gray-500">
{component.brand} {component.model}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{component.componentType.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
¥{component.price.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
component.stock > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{component.stock}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(component)}
className="text-blue-600 hover:text-blue-900 mr-4"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(component.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{editingComponent ? '编辑配件' : '添加配件'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">配件类型</label>
<select
value={formData.componentTypeId}
onChange={(e) => setFormData({ ...formData, componentTypeId: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
>
<option value="">选择类型</option>
{componentTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">配件名称</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">品牌</label>
<input
type="text"
value={formData.brand}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">型号</label>
<input
type="text"
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">价格</label>
<input
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">库存</label>
<input
type="number"
value={formData.stock}
onChange={(e) => setFormData({ ...formData, stock: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">描述</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">图片URL</label>
<input
type="url"
value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div className="flex justify-end space-x-4 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
取消
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingComponent ? '更新' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Batch Import Modal */}
{showBatchModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">批量导入配件</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">选择文件</label>
<input
type="file"
onChange={handleFileChange}
accept=".json,.csv"
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
{batchFile && (
<div className="mb-4">
<p className="text-sm text-gray-500">
已选择文件: <span className="font-medium">{batchFile.name}</span>
</p>
</div>
)}
<div className="mb-4">
<button
onClick={() => downloadTemplate('csv')}
className="mr-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Download className="h-5 w-5" />
下载CSV模板
</button>
<button
onClick={() => downloadTemplate('json')}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Download className="h-5 w-5" />
下载JSON模板
</button>
</div>
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => setShowBatchModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
取消
</button>
<button
onClick={handleBatchImport}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2"
disabled={batchImportLoading}
>
{batchImportLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
) : (
<Upload className="h-5 w-5" />
)}
批量导入
</button>
</div>
{batchData.length > 0 && (
<div className="mt-4">
<h3 className="text-lg font-semibold mb-2">预览数据</h3>
<div className="bg-gray-100 p-4 rounded-md max-h-[300px] overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{Object.keys(batchData[0]).map((key) => (
<th key={key} className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{key}
</th>
))}
</tr>
</thead> <tbody className="bg-white divide-y divide-gray-200">
{batchData.map((item, index) => (
<tr key={index}>
{Object.values(item).map((value, i) => (
<td key={i} className="px-4 py-2 whitespace-nowrap text-sm text-gray-900">
{String(value || '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
export default withAdminAuth(AdminComponentsPage)
+278
View File
@@ -0,0 +1,278 @@
'use client'
import { useState, useEffect } from 'react'
import { withAdminAuth } from '@/components/admin/AdminAuth'
import { Eye, Package, Calendar, User } from 'lucide-react'
interface Order {
id: string
orderNumber: string
totalAmount: number
status: string
createdAt: string
user: {
id: string
email: string
name?: string
username: string
}
orderItems: {
id: string
quantity: number
price: number
component: {
id: string
name: string
brand: string
model: string
}
}[]
}
const statusLabels: { [key: string]: string } = {
PENDING: '待处理',
CONFIRMED: '已确认',
PROCESSING: '处理中',
SHIPPED: '已发货',
DELIVERED: '已送达',
CANCELLED: '已取消'
}
const statusColors: { [key: string]: string } = {
PENDING: 'bg-yellow-100 text-yellow-800',
CONFIRMED: 'bg-blue-100 text-blue-800',
PROCESSING: 'bg-purple-100 text-purple-800',
SHIPPED: 'bg-green-100 text-green-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800'
}
function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
loadOrders()
}, [])
const loadOrders = async () => {
try {
const response = await fetch('/api/admin/orders')
if (response.ok) {
const data = await response.json()
setOrders(data)
}
} catch (error) {
console.error('加载订单失败:', error)
} finally {
setIsLoading(false)
}
}
const updateOrderStatus = async (orderId: string, newStatus: string) => {
try {
const response = await fetch(`/api/admin/orders/${orderId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: newStatus }),
})
if (response.ok) {
await loadOrders()
if (selectedOrder && selectedOrder.id === orderId) {
setSelectedOrder({ ...selectedOrder, status: newStatus })
}
} else {
alert('更新订单状态失败')
}
} catch (error) {
console.error('更新订单状态失败:', error)
alert('更新订单状态失败')
}
}
const viewOrderDetails = (order: Order) => {
setSelectedOrder(order)
setShowModal(true)
}
if (isLoading) {
return (
<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>
)
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">订单管理</h1>
<div className="text-sm text-gray-600">
共 {orders.length} 个订单
</div>
</div>
{/* Orders Table */}
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单号
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
用户
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
金额
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
创建时间
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{orders.map((order) => (
<tr key={order.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{order.orderNumber}
</div>
<div className="text-sm text-gray-500">
{order.orderItems.length} 个配件
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<User className="h-4 w-4 text-gray-400 mr-2" /> <div>
<div className="text-sm font-medium text-gray-900">
{order.user?.name || order.user?.username || '未知用户'}
</div>
<div className="text-sm text-gray-500">
{order.user?.email || '无邮箱'}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
¥{order.totalAmount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={order.status}
onChange={(e) => updateOrderStatus(order.id, e.target.value)}
className={`inline-flex px-2 py-1 text-xs rounded-full border-0 ${statusColors[order.status]}`}
>
{Object.entries(statusLabels).map(([status, label]) => (
<option key={status} value={status}>
{label}
</option>
))}
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-2" />
{new Date(order.createdAt).toLocaleDateString('zh-CN')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => viewOrderDetails(order)}
className="text-blue-600 hover:text-blue-900"
>
<Eye className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Order Details Modal */}
{showModal && selectedOrder && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">订单详情</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">订单号</label>
<div className="text-sm text-gray-900">{selectedOrder.orderNumber}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">状态</label>
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${statusColors[selectedOrder.status]}`}>
{statusLabels[selectedOrder.status]}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">用户</label> <div className="text-sm text-gray-900">
{selectedOrder.user?.name || selectedOrder.user?.username || '未知用户'}
</div>
<div className="text-sm text-gray-500">{selectedOrder.user?.email || '无邮箱'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">总金额</label>
<div className="text-sm text-gray-900">¥{selectedOrder.totalAmount.toLocaleString()}</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">订单商品</label>
<div className="border rounded-lg">
{selectedOrder.orderItems.map((item) => (
<div key={item.id} className="p-4 border-b last:border-b-0">
<div className="flex justify-between items-center"> <div>
<div className="font-medium">{item.component?.name || '未知商品'}</div>
<div className="text-sm text-gray-500">
{item.component?.brand || '未知品牌'} {item.component?.model || ''}
</div>
</div>
<div className="text-right">
<div className="font-medium">¥{item.price.toLocaleString()}</div>
<div className="text-sm text-gray-500">数量: {item.quantity}</div>
</div>
</div>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">创建时间</label>
<div className="text-sm text-gray-900">
{new Date(selectedOrder.createdAt).toLocaleString('zh-CN')}
</div>
</div>
</div>
<div className="flex justify-end space-x-4 pt-6">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
关闭
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default withAdminAuth(AdminOrdersPage)
+442
View File
@@ -0,0 +1,442 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import {
Users,
Package,
ShoppingCart,
DollarSign,
TrendingUp,
Eye,
BarChart3,
Settings,
Plus
} from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
interface StatsData {
overview: {
totalUsers: number
totalOrders: number
totalComponents: number
totalRevenue: number
}
topUsers: Array<{
id: string
username: string
name: string
email: string
totalSpending: number
orderCount: number
}>
topComponents: Array<{
id: string
name: string
brand: string
price: number
typeName: string
totalSales: number
totalRevenue: number
}>
recentOrders: Array<{
id: string
orderNumber: string
totalAmount: number
status: string
createdAt: string
user: {
username: string
name?: string
}
orderItems: Array<{
component: {
name: string
}
}>
}>
}
const COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899']
export default function AdminDashboard() {
const [stats, setStats] = useState<StatsData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('overview')
useEffect(() => {
checkAdminAccess()
loadStats()
}, [])
const checkAdminAccess = () => {
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (!user || !user.isAdmin) {
alert('权限不足,需要管理员权限')
window.location.href = '/'
return
}
}
const loadStats = async () => {
try {
const token = localStorage.getItem('token')
const response = await fetch('/api/admin/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const data = await response.json()
setStats(data)
} else if (response.status === 403) {
alert('权限不足')
window.location.href = '/'
}
} catch (error) {
console.error('加载统计数据失败:', error)
} finally {
setIsLoading(false)
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
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">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">管理后台</h1>
<p className="text-gray-600">系统管理与数据统计</p>
</div>
<div className="flex space-x-4">
<Link
href="/admin/components"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4 inline mr-2" />
添加商品
</Link>
<Link
href="/admin/orders"
className="border border-blue-600 text-blue-600 px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors"
>
<Eye className="h-4 w-4 inline mr-2" />
查看订单
</Link>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">总用户数</p>
<p className="text-2xl font-bold text-gray-900">{stats?.overview.totalUsers || 0}</p>
</div>
<Users className="h-12 w-12 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">总订单数</p>
<p className="text-2xl font-bold text-gray-900">{stats?.overview.totalOrders || 0}</p>
</div>
<ShoppingCart className="h-12 w-12 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">商品总数</p>
<p className="text-2xl font-bold text-gray-900">{stats?.overview.totalComponents || 0}</p>
</div>
<Package className="h-12 w-12 text-purple-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">总营收</p>
<p className="text-2xl font-bold text-gray-900">¥{stats?.overview.totalRevenue?.toFixed(2) || '0.00'}</p>
</div>
<DollarSign className="h-12 w-12 text-red-600" />
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="mb-8">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<BarChart3 className="h-4 w-4 inline mr-2" />
数据统计
</button>
<button
onClick={() => setActiveTab('users')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'users'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Users className="h-4 w-4 inline mr-2" />
用户排行
</button>
<button
onClick={() => setActiveTab('products')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'products'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Package className="h-4 w-4 inline mr-2" />
商品排行
</button>
<button
onClick={() => setActiveTab('orders')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'orders'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<ShoppingCart className="h-4 w-4 inline mr-2" />
最近订单
</button>
</nav>
</div>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Users Chart */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">用户消费金额排行</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats?.topUsers.slice(0, 5) || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value) => [`¥${value}`, '消费金额']} />
<Bar dataKey="totalSpending" fill="#3B82F6" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Top Components Chart */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">商品销量排行</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats?.topComponents.slice(0, 5) || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value) => [`${value}件`, '销量']} />
<Bar dataKey="totalSales" fill="#EF4444" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{activeTab === 'users' && (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">用户消费排行榜</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
排名
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
用户
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
邮箱
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单数
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
消费金额
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats?.topUsers.map((user, index) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
#{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.orderCount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-red-600">
¥{user.totalSpending.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'products' && (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">商品销量排行榜</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
排名
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
商品
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
品牌
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
类型
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
销量
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
营收
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats?.topComponents.map((component, index) => (
<tr key={component.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
#{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{component.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{component.brand}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{component.typeName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{component.totalSales}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-red-600">
¥{component.totalRevenue.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'orders' && (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">最近订单</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单号
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
用户
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
商品数量
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单金额
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
创建时间
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats?.recentOrders.map((order) => (
<tr key={order.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{order.orderNumber}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{order.user.name || order.user.username}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{order.orderItems.length} 件
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-red-600">
¥{order.totalAmount}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
{order.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(order.createdAt).toLocaleDateString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/admin-auth'
// 获取所有组件
export async function GET(request: NextRequest) {
try {
await requireAdmin(request)
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '20')
const type = searchParams.get('type')
const search = searchParams.get('search')
const where: any = {}
if (type) {
where.componentType = {
name: type
}
}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ brand: { contains: search, mode: 'insensitive' } },
]
}
const [components, total] = await Promise.all([
prisma.component.findMany({
where,
include: {
componentType: true
},
orderBy: {
createdAt: 'desc'
},
skip: (page - 1) * limit,
take: limit
}),
prisma.component.count({ where })
])
return NextResponse.json({
components,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit)
}
})
} catch (error: any) {
return NextResponse.json(
{ message: error.message || '获取组件列表失败' },
{ status: error.message === '权限不足' ? 403 : 500 }
)
}
}
// 创建新组件
export async function POST(request: NextRequest) {
try {
await requireAdmin(request)
const body = await request.json()
const { name, brand, model, price, description, imageUrl, stock, specifications, componentTypeId } = body
if (!name || !brand || !model || !price || !componentTypeId) {
return NextResponse.json(
{ message: '请填写所有必填字段' },
{ status: 400 }
)
}
const component = await prisma.component.create({
data: {
name,
brand,
model,
price: parseFloat(price),
description,
imageUrl,
stock: parseInt(stock) || 0,
specifications: specifications ? JSON.stringify(specifications) : null,
componentTypeId
},
include: {
componentType: true
}
})
return NextResponse.json(component, { status: 201 })
} catch (error: any) {
return NextResponse.json(
{ message: error.message || '创建组件失败' },
{ status: 500 }
)
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const { status } = await request.json()
const order = await prisma.order.update({
where: { id: id },
data: { status },
include: {
user: {
select: {
id: true,
email: true,
username: true,
name: true
}
},
orderItems: {
include: {
component: {
select: {
id: true,
name: true,
brand: true,
model: true
}
}
}
}
}
})
return NextResponse.json(order)
} catch (error) {
console.error('更新订单状态失败:', error)
return NextResponse.json(
{ message: '更新订单状态失败' },
{ status: 500 }
)
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
const orders = await prisma.order.findMany({
include: {
user: {
select: {
id: true,
email: true,
username: true,
name: true
}
}, orderItems: {
include: {
component: {
include: {
componentType: true
}
}
}
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(orders)
} catch (error) {
console.error('获取订单列表失败:', error)
return NextResponse.json(
{ message: '获取订单列表失败' },
{ status: 500 }
)
}
}
+123
View File
@@ -0,0 +1,123 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { requireAdmin } from '@/lib/admin-auth'
export async function GET(request: NextRequest) {
try {
await requireAdmin(request)
// 按消费金额倒排的前十用户
const topUsersBySpending = await prisma.user.findMany({
where: {
isAdmin: false,
orders: {
some: {}
}
},
select: {
id: true,
username: true,
name: true,
email: true,
orders: {
select: {
totalAmount: true
}
}
},
take: 10
})
const usersWithSpending = topUsersBySpending.map(user => ({
id: user.id,
username: user.username,
name: user.name || user.username,
email: user.email,
totalSpending: user.orders.reduce((sum, order) => sum + order.totalAmount, 0),
orderCount: user.orders.length
})).sort((a, b) => b.totalSpending - a.totalSpending)
// 按销售量倒排序的前十配件
const topComponentsBySales = await prisma.component.findMany({
select: {
id: true,
name: true,
brand: true,
price: true,
componentType: {
select: {
name: true
}
},
orderItems: {
select: {
quantity: true
}
}
}
})
const componentsWithSales = topComponentsBySales.map(component => ({
id: component.id,
name: component.name,
brand: component.brand,
price: component.price,
typeName: component.componentType.name,
totalSales: component.orderItems.reduce((sum, item) => sum + item.quantity, 0),
totalRevenue: component.orderItems.reduce((sum, item) => sum + (item.quantity * component.price), 0)
})).sort((a, b) => b.totalSales - a.totalSales).slice(0, 10)
// 总体统计
const totalUsers = await prisma.user.count({ where: { isAdmin: false } })
const totalOrders = await prisma.order.count()
const totalComponents = await prisma.component.count()
const totalRevenue = await prisma.order.aggregate({
_sum: {
totalAmount: true
}
})
const recentOrders = await prisma.order.findMany({
take: 5,
orderBy: {
createdAt: 'desc'
},
include: {
user: {
select: {
username: true,
name: true
}
},
orderItems: {
include: {
component: {
select: {
name: true
}
}
}
}
}
})
return NextResponse.json({
overview: {
totalUsers,
totalOrders,
totalComponents,
totalRevenue: totalRevenue._sum.totalAmount || 0
},
topUsers: usersWithSpending,
topComponents: componentsWithSales,
recentOrders
})
} catch (error: any) {
console.error('获取统计数据失败:', error)
return NextResponse.json(
{ message: error.message || '获取统计数据失败' },
{ status: error.message === '权限不足' ? 403 : 500 }
)
}
}
+59
View File
@@ -0,0 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyPassword, generateToken } from '@/lib/auth'
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json()
if (!email || !password) {
return NextResponse.json(
{ message: '邮箱和密码不能为空' },
{ status: 400 }
)
}
// 查找用户
const user = await prisma.user.findUnique({
where: { email }
})
if (!user) {
return NextResponse.json(
{ message: '用户不存在' },
{ status: 401 }
)
}
// 验证密码
const isValidPassword = await verifyPassword(password, user.password)
if (!isValidPassword) {
return NextResponse.json(
{ message: '密码错误' },
{ status: 401 }
)
}
// 生成token
const token = generateToken({
userId: user.id,
email: user.email,
isAdmin: user.isAdmin
})
// 返回用户信息(不包含密码)
const { password: _, ...userWithoutPassword } = user
return NextResponse.json({
message: '登录成功',
token,
user: userWithoutPassword
})
} catch (error) {
console.error('Login error:', error)
return NextResponse.json(
{ message: '服务器错误' },
{ status: 500 }
)
}
}
+70
View File
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { hashPassword } from '@/lib/auth'
export async function POST(request: NextRequest) {
try {
const { email, username, password, name, phone, address } = await request.json()
if (!email || !username || !password) {
return NextResponse.json(
{ message: '邮箱、用户名和密码不能为空' },
{ status: 400 }
)
}
// 检查邮箱是否已存在
const existingUserByEmail = await prisma.user.findUnique({
where: { email }
})
if (existingUserByEmail) {
return NextResponse.json(
{ message: '该邮箱已被注册' },
{ status: 400 }
)
}
// 检查用户名是否已存在
const existingUserByUsername = await prisma.user.findUnique({
where: { username }
})
if (existingUserByUsername) {
return NextResponse.json(
{ message: '该用户名已被使用' },
{ status: 400 }
)
}
// 加密密码
const hashedPassword = await hashPassword(password)
// 创建用户
const user = await prisma.user.create({
data: {
email,
username,
password: hashedPassword,
name: name || null,
phone: phone || null,
address: address || null,
isAdmin: false
}
})
// 返回用户信息(不包含密码)
const { password: _, ...userWithoutPassword } = user
return NextResponse.json({
message: '注册成功',
user: userWithoutPassword
}, { status: 201 })
} catch (error) {
console.error('Register error:', error)
return NextResponse.json(
{ message: '服务器错误' },
{ status: 500 }
)
}
}
+139
View File
@@ -0,0 +1,139 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken } from '@/lib/auth'
// 更新购物车项数量
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
const { quantity } = await request.json()
if (!quantity || quantity < 1) {
return NextResponse.json(
{ message: '数量必须大于0' },
{ status: 400 }
)
}
// 检查购物车项是否属于当前用户
const cartItem = await prisma.cartItem.findFirst({
where: {
id: id,
userId: decoded.userId
},
include: {
component: true
}
})
if (!cartItem) {
return NextResponse.json(
{ message: '购物车项不存在' },
{ status: 404 }
)
}
// 检查库存
if (quantity > cartItem.component.stock) {
return NextResponse.json(
{ message: '库存不足' },
{ status: 400 }
)
}
const updatedCartItem = await prisma.cartItem.update({
where: { id: id },
data: { quantity: quantity },
include: {
component: {
include: {
componentType: true
}
}
}
})
return NextResponse.json(updatedCartItem)
} catch (error) {
console.error('更新购物车项失败:', error)
return NextResponse.json(
{ message: '更新购物车项失败' },
{ status: 500 }
)
}
}
// 删除购物车项
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
// 检查购物车项是否属于当前用户
const cartItem = await prisma.cartItem.findFirst({
where: {
id: id,
userId: decoded.userId
}
})
if (!cartItem) {
return NextResponse.json(
{ message: '购物车项不存在' },
{ status: 404 }
)
}
await prisma.cartItem.delete({
where: { id: id }
})
return NextResponse.json({ message: '已从购物车移除' })
} catch (error) {
console.error('删除购物车项失败:', error)
return NextResponse.json(
{ message: '删除购物车项失败' },
{ status: 500 }
)
}
}
+188
View File
@@ -0,0 +1,188 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken } from '@/lib/auth'
// 获取购物车
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
const cartItems = await prisma.cartItem.findMany({
where: { userId: decoded.userId },
include: {
component: {
include: {
componentType: true
}
}
},
orderBy: { createdAt: 'desc' }
})
return NextResponse.json(cartItems)
} catch (error) {
console.error('获取购物车失败:', error)
return NextResponse.json(
{ message: '获取购物车失败' },
{ status: 500 }
)
}
}
// 添加商品到购物车
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
const { componentId, quantity = 1 } = await request.json()
if (!componentId) {
return NextResponse.json(
{ message: '缺少商品ID' },
{ status: 400 }
)
}
// 检查商品是否存在
const component = await prisma.component.findUnique({
where: { id: componentId }
})
if (!component) {
return NextResponse.json(
{ message: '商品不存在' },
{ status: 404 }
)
}
if (component.stock < quantity) {
return NextResponse.json(
{ message: '库存不足' },
{ status: 400 }
)
}
// 检查购物车中是否已存在该商品
const existingCartItem = await prisma.cartItem.findUnique({
where: {
userId_componentId: {
userId: decoded.userId,
componentId: componentId
}
}
})
let cartItem
if (existingCartItem) {
// 更新数量
const newQuantity = existingCartItem.quantity + quantity
if (newQuantity > component.stock) {
return NextResponse.json(
{ message: '超出库存限制' },
{ status: 400 }
)
}
cartItem = await prisma.cartItem.update({
where: { id: existingCartItem.id },
data: { quantity: newQuantity },
include: {
component: {
include: {
componentType: true
}
}
}
})
} else {
// 创建新的购物车项
cartItem = await prisma.cartItem.create({
data: {
userId: decoded.userId,
componentId: componentId,
quantity: quantity
},
include: {
component: {
include: {
componentType: true
}
}
}
})
}
return NextResponse.json(cartItem, { status: 201 })
} catch (error) {
console.error('添加到购物车失败:', error)
return NextResponse.json(
{ message: '添加到购物车失败' },
{ status: 500 }
)
}
}
// 清空购物车
export async function DELETE(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
await prisma.cartItem.deleteMany({
where: { userId: decoded.userId }
})
return NextResponse.json({ message: '购物车已清空' })
} catch (error) {
console.error('清空购物车失败:', error)
return NextResponse.json(
{ message: '清空购物车失败' },
{ status: 500 }
)
}
}
+27
View File
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET() {
try {
const componentTypes = await prisma.componentType.findMany({
include: {
_count: {
select: {
components: true
}
}
},
orderBy: {
name: 'asc'
}
})
return NextResponse.json(componentTypes)
} catch (error) {
console.error('Component types fetch error:', error)
return NextResponse.json(
{ message: '获取配件类型失败' },
{ status: 500 }
)
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const component = await prisma.component.findUnique({
where: { id: id },
include: {
componentType: true
}
})
if (!component) {
return NextResponse.json(
{ message: '配件不存在' },
{ status: 404 }
)
}
return NextResponse.json(component)
} catch (error) {
console.error('Component fetch error:', error)
return NextResponse.json(
{ message: '获取配件详情失败' },
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const data = await request.json()
const component = await prisma.component.update({
where: { id: id },
data: {
name: data.name,
brand: data.brand,
model: data.model,
price: parseFloat(data.price),
description: data.description,
imageUrl: data.imageUrl,
stock: parseInt(data.stock),
specifications: data.specifications,
componentTypeId: data.componentTypeId
},
include: {
componentType: true
}
})
return NextResponse.json(component)
} catch (error) {
console.error('Component update error:', error)
return NextResponse.json(
{ message: '更新配件失败' },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
await prisma.component.delete({
where: { id: id }
})
return NextResponse.json({ message: '删除成功' })
} catch (error) {
console.error('Component delete error:', error)
return NextResponse.json(
{ message: '删除配件失败' },
{ status: 500 }
)
}
}
+215
View File
@@ -0,0 +1,215 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
interface BatchComponentData {
name: string
brand: string
model: string
price: number
description?: string
imageUrl?: string
stock: number
specifications?: string
componentTypeId?: string
typeName?: string
type?: string
}
interface BatchResult {
success: boolean
item: BatchComponentData
error?: string
componentId?: string
}
export async function POST(request: NextRequest) {
try {
// 这个API需要管理员权限,实际应用中需要验证
const { components } = await request.json()
if (!Array.isArray(components) || components.length === 0) {
return NextResponse.json(
{ message: '请提供有效的配件数据数组' },
{ status: 400 }
)
}
const results: BatchResult[] = []
const createdTypes: { [key: string]: string } = {} // 缓存新创建的类型
// 获取现有的配件类型,用于匹配
const existingTypes = await prisma.componentType.findMany()
const typeMap = new Map(existingTypes.map(type => [type.name.toLowerCase(), type.id]))
for (const componentData of components) {
try {
// 验证必需字段
if (!componentData.name || !componentData.brand || !componentData.model) {
results.push({
success: false,
item: componentData,
error: '缺少必需字段: name, brand, model'
})
continue
}
// 处理价格和库存
const price = typeof componentData.price === 'string'
? parseFloat(componentData.price)
: componentData.price
const stock = typeof componentData.stock === 'string'
? parseInt(componentData.stock)
: componentData.stock
if (isNaN(price) || price < 0) {
results.push({
success: false,
item: componentData,
error: '价格必须是有效的正数'
})
continue
}
if (isNaN(stock) || stock < 0) {
results.push({
success: false,
item: componentData,
error: '库存必须是有效的非负整数'
})
continue
}
// 处理配件类型
let componentTypeId = componentData.componentTypeId
if (!componentTypeId) {
const typeName = componentData.typeName || componentData.type
if (!typeName) {
results.push({
success: false,
item: componentData,
error: '缺少配件类型信息 (componentTypeId, typeName 或 type)'
})
continue
}
const lowerTypeName = typeName.toLowerCase()
// 检查是否已存在
if (typeMap.has(lowerTypeName)) {
componentTypeId = typeMap.get(lowerTypeName)
} else if (createdTypes[lowerTypeName]) {
// 检查本次批量导入中是否已创建
componentTypeId = createdTypes[lowerTypeName]
} else {
// 创建新的配件类型
try {
const newType = await prisma.componentType.create({
data: {
name: typeName,
description: `自动创建的${typeName}类型`
}
})
componentTypeId = newType.id
createdTypes[lowerTypeName] = newType.id
typeMap.set(lowerTypeName, newType.id)
} catch (typeError) {
results.push({
success: false,
item: componentData,
error: `创建配件类型失败: ${typeError instanceof Error ? typeError.message : '未知错误'}`
})
continue
}
}
}
// 验证配件类型ID
if (!componentTypeId) {
results.push({
success: false,
item: componentData,
error: '无法确定配件类型'
})
continue
}
// 检查是否已存在相同的配件
const existingComponent = await prisma.component.findFirst({
where: {
name: componentData.name,
brand: componentData.brand,
model: componentData.model
}
})
if (existingComponent) {
results.push({
success: false,
item: componentData,
error: `配件已存在: ${componentData.name} - ${componentData.brand} ${componentData.model}`
})
continue
}
// 创建配件
const newComponent = await prisma.component.create({
data: {
name: componentData.name.trim(),
brand: componentData.brand.trim(),
model: componentData.model.trim(),
price,
description: componentData.description?.trim() || '',
imageUrl: componentData.imageUrl?.trim() || '',
stock,
specifications: componentData.specifications?.trim() || '',
componentTypeId
},
include: {
componentType: true
}
})
results.push({
success: true,
item: componentData,
componentId: newComponent.id
})
} catch (error) {
console.error('处理单个配件时出错:', error)
results.push({
success: false,
item: componentData,
error: error instanceof Error ? error.message : '创建配件失败'
})
}
}
// 统计结果
const successCount = results.filter(r => r.success).length
const failCount = results.filter(r => !r.success).length
return NextResponse.json({
success: true,
results,
summary: {
total: components.length,
successful: successCount,
failed: failCount,
newTypesCreated: Object.keys(createdTypes).length
}
})
} catch (error) {
console.error('批量导入配件失败:', error)
return NextResponse.json(
{
success: false,
message: '批量导入失败',
error: error instanceof Error ? error.message : '未知错误'
},
{ status: 500 }
)
}
}
+110
View File
@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const typeId = searchParams.get('type')
const brand = searchParams.get('brand')
const minPrice = searchParams.get('minPrice')
const maxPrice = searchParams.get('maxPrice')
const search = searchParams.get('search')
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '12')
const where: any = {}
if (typeId) {
where.componentTypeId = typeId
}
if (brand) {
where.brand = {
contains: brand,
mode: 'insensitive'
}
}
if (minPrice || maxPrice) {
where.price = {}
if (minPrice) where.price.gte = parseFloat(minPrice)
if (maxPrice) where.price.lte = parseFloat(maxPrice)
}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ brand: { contains: search, mode: 'insensitive' } },
{ model: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
]
}
const [components, total] = await Promise.all([
prisma.component.findMany({
where,
include: {
componentType: true
},
orderBy: {
createdAt: 'desc'
},
skip: (page - 1) * limit,
take: limit
}),
prisma.component.count({ where })
])
const totalPages = Math.ceil(total / limit)
return NextResponse.json({
components,
pagination: {
page,
limit,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1
}
})
} catch (error) {
console.error('Components fetch error:', error)
return NextResponse.json(
{ message: '获取配件列表失败' },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
// 这个API需要管理员权限,暂时简化处理
const data = await request.json()
const component = await prisma.component.create({
data: {
name: data.name,
brand: data.brand,
model: data.model,
price: parseFloat(data.price),
description: data.description,
imageUrl: data.imageUrl,
stock: parseInt(data.stock),
specifications: data.specifications,
componentTypeId: data.componentTypeId
},
include: {
componentType: true
}
})
return NextResponse.json(component, { status: 201 })
} catch (error) {
console.error('Component create error:', error)
return NextResponse.json(
{ message: '创建配件失败' },
{ status: 500 }
)
}
}
+165
View File
@@ -0,0 +1,165 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken } from '@/lib/auth'
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
const order = await prisma.order.findFirst({
where: {
id: id,
userId: decoded.userId
},
include: {
orderItems: {
include: {
component: {
include: {
componentType: true
}
}
}
},
user: {
select: {
username: true,
email: true
}
}
}
})
if (!order) {
return NextResponse.json(
{ message: '订单不存在' },
{ status: 404 }
)
}
return NextResponse.json(order)
} catch (error) {
console.error('获取订单详情失败:', error)
return NextResponse.json(
{ message: '获取订单详情失败' },
{ status: 500 }
)
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
const { action } = await request.json()
// 查找订单
const order = await prisma.order.findFirst({
where: {
id: id,
userId: decoded.userId
},
include: {
orderItems: true
}
})
if (!order) {
return NextResponse.json(
{ message: '订单不存在' },
{ status: 404 }
)
}
// 处理不同的操作
if (action === 'cancel') {
// 只有待确认状态的订单可以取消
if (order.status !== 'PENDING') {
return NextResponse.json(
{ message: '当前订单状态不允许取消' },
{ status: 400 }
)
} // 更新订单状态为取消
const updatedOrder = await prisma.order.update({
where: { id: id },
data: { status: 'CANCELLED' },
include: {
orderItems: {
include: {
component: {
include: {
componentType: true
}
}
}
}
}
})
// 恢复库存
for (const item of order.orderItems) {
await prisma.component.update({
where: { id: item.componentId },
data: {
stock: {
increment: item.quantity
}
}
})
}
return NextResponse.json(updatedOrder)
}
return NextResponse.json(
{ message: '不支持的操作' },
{ status: 400 }
)
} catch (error) {
console.error('更新订单失败:', error)
return NextResponse.json(
{ message: '更新订单失败' },
{ status: 500 }
)
}
}
+155
View File
@@ -0,0 +1,155 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken } from '@/lib/auth'
export async function POST(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
const { items } = await request.json()
if (!items || items.length === 0) {
return NextResponse.json(
{ message: '订单商品不能为空' },
{ status: 400 }
)
}
// 生成订单号
const orderNumber = `ORD${Date.now()}`
// 计算总金额
let totalAmount = 0
const orderItems = []
for (const item of items) {
const component = await prisma.component.findUnique({
where: { id: item.componentId }
})
if (!component) {
return NextResponse.json(
{ message: `配件不存在: ${item.componentId}` },
{ status: 400 }
)
}
if (component.stock < item.quantity) {
return NextResponse.json(
{ message: `配件库存不足: ${component.name}` },
{ status: 400 }
)
}
const itemTotal = component.price * item.quantity
totalAmount += itemTotal
orderItems.push({
componentId: component.id,
quantity: item.quantity,
price: component.price
})
}
// 创建订单
const order = await prisma.order.create({
data: {
orderNumber,
totalAmount,
userId: decoded.userId,
orderItems: {
create: orderItems
}
}, include: {
orderItems: {
include: {
component: {
include: {
componentType: true
}
}
}
}
}
})
// 更新库存
for (const item of items) {
await prisma.component.update({
where: { id: item.componentId },
data: {
stock: {
decrement: item.quantity
}
}
})
}
return NextResponse.json(order, { status: 201 })
} catch (error) {
console.error('创建订单失败:', error)
return NextResponse.json(
{ message: '创建订单失败' },
{ status: 500 }
)
}
}
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
} const orders = await prisma.order.findMany({
where: { userId: decoded.userId },
include: {
orderItems: {
include: {
component: {
include: {
componentType: true
}
}
}
}
},
orderBy: {
createdAt: 'desc'
}
})
return NextResponse.json(orders)
} catch (error) {
console.error('获取订单列表失败:', error)
return NextResponse.json(
{ message: '获取订单列表失败' },
{ status: 500 }
)
}
}
+61
View File
@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken, hashPassword, verifyPassword } from '@/lib/auth'
export async function PUT(request: NextRequest) {
try {
const authHeader = request.headers.get('Authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ message: '未提供认证令牌' }, { status: 401 })
}
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json({ message: '无效的认证令牌' }, { status: 401 })
}
const { currentPassword, newPassword } = await request.json()
if (!currentPassword || !newPassword) {
return NextResponse.json({ message: '请提供当前密码和新密码' }, { status: 400 })
}
if (newPassword.length < 6) {
return NextResponse.json({ message: '新密码长度至少6位' }, { status: 400 })
}
// 获取用户当前信息
const user = await prisma.user.findUnique({
where: { id: decoded.userId }
})
if (!user) {
return NextResponse.json({ message: '用户不存在' }, { status: 404 })
}
// 验证当前密码
const isCurrentPasswordValid = await verifyPassword(currentPassword, user.password)
if (!isCurrentPasswordValid) {
return NextResponse.json({ message: '当前密码错误' }, { status: 400 })
}
// 哈希新密码
const hashedNewPassword = await hashPassword(newPassword)
// 更新密码
await prisma.user.update({
where: { id: decoded.userId },
data: {
password: hashedNewPassword
}
})
return NextResponse.json({ message: '密码修改成功' })
} catch (error) {
console.error('修改密码失败:', error)
return NextResponse.json({ message: '修改密码失败' }, { status: 500 })
}
}
+86
View File
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('Authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ message: '未提供认证令牌' }, { status: 401 })
}
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json({ message: '无效的认证令牌' }, { status: 401 })
}
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
email: true,
username: true,
name: true,
phone: true,
address: true,
isAdmin: true,
createdAt: true
}
})
if (!user) {
return NextResponse.json({ message: '用户不存在' }, { status: 404 })
}
return NextResponse.json(user)
} catch (error) {
console.error('获取用户资料失败:', error)
return NextResponse.json({ message: '获取用户资料失败' }, { status: 500 })
}
}
export async function PUT(request: NextRequest) {
try {
const authHeader = request.headers.get('Authorization')
const token = authHeader?.replace('Bearer ', '')
if (!token) {
return NextResponse.json({ message: '未提供认证令牌' }, { status: 401 })
}
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json({ message: '无效的认证令牌' }, { status: 401 })
}
const { name, phone, address } = await request.json()
const updatedUser = await prisma.user.update({
where: { id: decoded.userId },
data: {
name,
phone,
address
},
select: {
id: true,
email: true,
username: true,
name: true,
phone: true,
address: true,
isAdmin: true,
createdAt: true
}
})
return NextResponse.json(updatedUser)
} catch (error) {
console.error('更新用户资料失败:', error)
return NextResponse.json({ message: '更新用户资料失败' }, { status: 500 })
}
}
+170
View File
@@ -0,0 +1,170 @@
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { verifyToken } from '@/lib/auth'
export async function GET(request: NextRequest) {
try {
const authHeader = request.headers.get('authorization')
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ message: '请先登录' },
{ status: 401 }
)
}
const token = authHeader.substring(7)
const decoded = verifyToken(token)
if (!decoded) {
return NextResponse.json(
{ message: '登录已过期' },
{ status: 401 }
)
}
// 获取用户基本信息
const user = await prisma.user.findUnique({
where: { id: decoded.userId },
select: {
id: true,
createdAt: true,
username: true,
email: true
}
})
if (!user) {
return NextResponse.json(
{ message: '用户不存在' },
{ status: 404 }
)
}
// 获取订单统计
const orderStats = await prisma.order.groupBy({
by: ['status'],
where: {
userId: decoded.userId
},
_count: {
id: true
},
_sum: {
totalAmount: true
}
})
// 获取总订单数和总消费
const totalOrders = await prisma.order.count({
where: {
userId: decoded.userId
}
})
const totalSpent = await prisma.order.aggregate({
where: {
userId: decoded.userId,
status: {
in: ['CONFIRMED', 'SHIPPED', 'DELIVERED']
}
},
_sum: {
totalAmount: true
}
}) // 获取购物车商品数量
const cartItemsCount = await prisma.cartItem.count({
where: {
userId: decoded.userId
}
})
// 获取最近订单
const recentOrders = await prisma.order.findMany({
where: {
userId: decoded.userId
},
orderBy: {
createdAt: 'desc'
},
take: 5,
select: {
id: true,
status: true,
totalAmount: true,
createdAt: true
}
})
// 按状态分组订单统计
const ordersByStatus = {
PENDING: 0,
CONFIRMED: 0,
SHIPPED: 0,
DELIVERED: 0,
CANCELLED: 0
}
orderStats.forEach(stat => {
ordersByStatus[stat.status as keyof typeof ordersByStatus] = stat._count.id
})
// 获取用户最喜欢的配件类型(购买最多的类型)
const favoriteComponentType = await prisma.orderItem.groupBy({
by: ['componentId'],
where: {
order: {
userId: decoded.userId,
status: {
in: ['CONFIRMED', 'SHIPPED', 'DELIVERED']
}
}
},
_sum: {
quantity: true
},
orderBy: {
_sum: {
quantity: 'desc'
}
},
take: 1
})
let favoriteType = null
if (favoriteComponentType.length > 0) {
const component = await prisma.component.findUnique({
where: { id: favoriteComponentType[0].componentId },
include: {
componentType: true
}
})
favoriteType = component?.componentType?.name || null
} return NextResponse.json({
user: {
id: user.id,
username: user.username,
email: user.email,
memberSince: user.createdAt
},
orderStats: {
total: totalOrders,
byStatus: ordersByStatus,
totalSpent: totalSpent._sum.totalAmount || 0
},
recentOrders,
favoriteComponentType: favoriteType, summary: {
totalOrders,
totalSpent: totalSpent._sum.totalAmount || 0,
pendingOrders: ordersByStatus.PENDING,
completedOrders: ordersByStatus.DELIVERED,
cartItems: cartItemsCount
}
})
} catch (error) {
console.error('获取用户统计失败:', error)
return NextResponse.json(
{ message: '获取统计数据失败' },
{ status: 500 }
)
}
}
+456
View File
@@ -0,0 +1,456 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Check, Plus, Package, ShoppingCart, Cpu, HardDrive, MemoryStick, Zap, Monitor, Box, Search, X } from 'lucide-react'
interface ComponentType {
id: string
name: string
description?: string
}
interface Component {
id: string
name: string
brand: string
price: number
imageUrl?: string
componentType: ComponentType
}
interface BuildConfiguration {
[key: string]: Component | null
}
const componentIcons: { [key: string]: any } = {
'CPU': Cpu,
'内存': MemoryStick,
'硬盘': HardDrive,
'主板': Zap,
'显卡': Monitor,
'机箱': Box,
}
export default function BuildPage() { const [componentTypes, setComponentTypes] = useState<ComponentType[]>([])
const [availableComponents, setAvailableComponents] = useState<{ [key: string]: Component[] }>({})
const [buildConfig, setBuildConfig] = useState<BuildConfiguration>({})
const [isLoading, setIsLoading] = useState(true)
const [selectedType, setSelectedType] = useState<string | null>(null)
const [searchTerm, setSearchTerm] = useState('')
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
// 加载配件类型
const typesResponse = await fetch('/api/component-types')
if (typesResponse.ok) {
const types = await typesResponse.json()
setComponentTypes(types) // 为每种类型加载组件
const componentsData: { [key: string]: Component[] } = {}
for (const type of types) {
const componentsResponse = await fetch(`/api/components?type=${encodeURIComponent(type.id)}&limit=1000`)
if (componentsResponse.ok) {
const components = await componentsResponse.json()
componentsData[type.name] = components.components || []
}
}
setAvailableComponents(componentsData)
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
setIsLoading(false)
}
}
const selectComponent = (typeName: string, component: Component) => {
setBuildConfig(prev => ({
...prev,
[typeName]: component
}))
setSelectedType(null)
setSearchTerm('')
}
const removeComponent = (typeName: string) => {
setBuildConfig(prev => ({
...prev,
[typeName]: null
}))
}
const getTotalPrice = () => {
return Object.values(buildConfig).reduce((total, component) => {
return total + (component?.price || 0)
}, 0)
}
const getCompletionRate = () => {
const totalTypes = componentTypes.length
const selectedTypes = Object.values(buildConfig).filter(Boolean).length
return totalTypes > 0 ? Math.round((selectedTypes / totalTypes) * 100) : 0
}
const addAllToCart = async () => {
const selectedComponents = Object.values(buildConfig).filter(Boolean)
if (selectedComponents.length === 0) {
alert('请先选择配件')
return
}
const token = localStorage.getItem('token')
if (!token) {
alert('请先登录')
return
}
try {
// 批量添加到购物车
for (const component of selectedComponents) {
await fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
componentId: component!.id,
quantity: 1
})
})
}
window.dispatchEvent(new Event('cart-updated'))
alert('配置已添加到购物车!')
} catch (error) {
console.error('添加到购物车失败:', error)
alert('添加失败,请重试')
}
}
const createOrder = async () => {
const selectedComponents = Object.values(buildConfig).filter(Boolean)
if (selectedComponents.length !== componentTypes.length) {
alert('请完成所有配件的选择后再下单')
return
}
try {
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (!user) {
alert('请先登录')
window.location.href = '/login'
return
}
const orderItems = selectedComponents.map(component => ({
componentId: component!.id,
quantity: 1,
price: component!.price
}))
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
items: orderItems,
totalAmount: getTotalPrice()
})
})
if (response.ok) {
alert('订单创建成功!')
setBuildConfig({})
window.location.href = '/orders'
} else {
alert('订单创建失败,请重试')
}
} catch (error) {
console.error('创建订单失败:', error)
alert('订单创建失败,请重试')
}
}
const getFilteredComponents = (typeName: string) => {
console.log(availableComponents,typeName);
const components = availableComponents[typeName] || []
if (!searchTerm) return components
return components.filter(component =>
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.brand.toLowerCase().includes(searchTerm.toLowerCase())
)
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
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">
{/* Header */}
<div className="text-center mb-12">
<h1 className="text-4xl font-bold text-gray-900 mb-4">PC装机配置</h1>
<p className="text-xl text-gray-600 mb-8">选择每种配件,组装您的专属电脑</p>
{/* Progress */}
<div className="max-w-md mx-auto">
<div className="flex justify-between text-sm text-gray-600 mb-2">
<span>配置进度</span>
<span>{getCompletionRate()}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300"
style={{ width: `${getCompletionRate()}%` }}
></div>
</div>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-8">
{/* Components Selection */}
<div className="lg:col-span-3">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{componentTypes.map((type) => {
const Icon = componentIcons[type.name] || Package
const selectedComponent = buildConfig[type.name]
return (
<div key={type.id} className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center space-x-3">
<Icon className="h-6 w-6 text-blue-600" />
<div>
<h3 className="font-semibold text-gray-900">{type.name}</h3>
<p className="text-sm text-gray-600">{type.description}</p>
</div>
</div>
{selectedComponent && (
<Check className="h-6 w-6 text-green-600" />
)}
</div>
{selectedComponent ? (
<div className="space-y-4">
<div className="border rounded-lg p-4 bg-green-50">
<div className="flex items-center space-x-4">
<div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
{selectedComponent.imageUrl ? (
<img loading='lazy'
src={selectedComponent.imageUrl}
alt={selectedComponent.name}
className="max-w-full max-h-full object-contain"
/>
) : (
<Package className="h-8 w-8 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900">{selectedComponent.name}</h4>
<p className="text-sm text-gray-600">{selectedComponent.brand}</p>
<p className="text-lg font-bold text-red-600">¥{selectedComponent.price}</p>
</div>
</div>
</div>
<div className="flex space-x-2">
<button
onClick={() => setSelectedType(type.name)}
className="flex-1 text-blue-600 border border-blue-600 py-2 px-4 rounded-lg hover:bg-blue-50 transition-colors"
>
更换
</button>
<button
onClick={() => removeComponent(type.name)}
className="text-red-600 border border-red-600 py-2 px-4 rounded-lg hover:bg-red-50 transition-colors"
>
移除
</button>
</div>
</div>
) : (
<button
onClick={() => setSelectedType(type.name)}
className="w-full border-2 border-dashed border-gray-300 rounded-lg py-8 text-gray-500 hover:border-blue-300 hover:text-blue-600 transition-colors"
>
<Plus className="h-8 w-8 mx-auto mb-2" />
<p>选择 {type.name}</p>
</button>
)}
</div>
)
})}
</div>
</div>
{/* Summary */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-sm p-6 sticky top-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">配置总览</h2>
<div className="space-y-3 mb-6">
{componentTypes.map((type) => {
const component = buildConfig[type.name]
return (
<div key={type.id} className="flex justify-between text-sm">
<span className="text-gray-600">{type.name}:</span>
<span className={component ? "text-gray-900 font-medium" : "text-gray-400"}>
{component ? `¥${component.price}` : '未选择'}
</span>
</div>
)
})}
<div className="border-t border-gray-200 pt-3">
<div className="flex justify-between font-semibold">
<span>总价:</span>
<span className="text-red-600">¥{getTotalPrice().toFixed(2)}</span>
</div>
</div>
</div>
<div className="space-y-3">
<button
onClick={addAllToCart}
disabled={Object.values(buildConfig).filter(Boolean).length === 0}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors"
>
<ShoppingCart className="h-5 w-5 inline mr-2" />
加入购物车
</button>
<button
onClick={createOrder}
disabled={getCompletionRate() < 100}
className="w-full border border-green-600 text-green-600 py-3 px-4 rounded-lg hover:bg-green-50 disabled:border-gray-300 disabled:text-gray-400 disabled:cursor-not-allowed transition-colors"
>
直接下单
</button>
</div>
<p className="text-xs text-gray-500 mt-4">
完成度: {getCompletionRate()}% ({Object.values(buildConfig).filter(Boolean).length}/{componentTypes.length})
</p>
</div>
</div>
</div>
{/* Component Selection Modal */}
{selectedType && (
<div className="fixed inset-0 bg-black bg-opacity-30 backdrop-blur-sm flex items-center justify-center p-4 z-50"> <div className="bg-white rounded-xl max-w-5xl w-full max-h-[85vh] overflow-hidden shadow-2xl">
<div className="p-6 border-b border-gray-200 bg-gray-50">
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-semibold text-gray-900">选择 {selectedType}</h3>
<button
onClick={() => {
setSelectedType(null)
setSearchTerm('')
}}
className="text-gray-400 hover:text-gray-600 p-2 hover:bg-gray-200 rounded-full transition-colors"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Search Box */}
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="h-5 w-5 text-gray-400" />
</div>
<input
type="text"
placeholder={`搜索${selectedType}配件...`}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg leading-5 bg-white placeholder-gray-500 focus:outline-none focus:placeholder-gray-400 focus:ring-1 focus:ring-blue-500 focus:border-blue-500 transition-colors"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-0 pr-3 flex items-center"
>
<X className="h-4 w-4 text-gray-400 hover:text-gray-600" />
</button>
)}
</div>
{/* Results count */}
<div className="mt-3 text-sm text-gray-600">
找到 {getFilteredComponents(selectedType).length} 个配件
{searchTerm && ` (搜索: "${searchTerm}")`}
</div>
</div>
<div className="p-6 overflow-y-auto" style={{ maxHeight: 'calc(85vh - 200px)' }}>
{getFilteredComponents(selectedType).length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{getFilteredComponents(selectedType).map((component) => (
<div
key={component.id}
className="border border-gray-200 rounded-lg p-5 hover:shadow-lg hover:border-blue-300 cursor-pointer transition-all duration-200 bg-white"
onClick={() => selectComponent(selectedType, component)}
>
<div className="w-full h-36 bg-gray-50 rounded-lg mb-4 flex items-center justify-center overflow-hidden">
{component.imageUrl ? (
<img loading='lazy'
src={component.imageUrl}
alt={component.name}
className="max-w-full max-h-full object-contain"
/>
) : (
<Package className="h-12 w-12 text-gray-400" />
)}
</div>
<h4 className="font-medium text-gray-900 mb-2 line-clamp-2 text-sm">
{component.name}
</h4>
<p className="text-sm text-gray-600 mb-3">{component.brand}</p>
<div className="flex items-center justify-between">
<p className="text-lg font-bold text-red-600">¥{component.price}</p>
<button className="text-blue-600 hover:text-blue-800 text-sm font-medium">
选择
</button>
</div>
</div>
))}
</div>
) : (
<div className="text-center py-12">
<Package className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">
{searchTerm ? '没有找到匹配的配件' : '暂无配件'}
</h3>
<p className="text-gray-500">
{searchTerm ? '尝试调整搜索关键词' : '此类型暂无可用配件'}
</p>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="mt-4 text-blue-600 hover:text-blue-800 font-medium"
>
清除搜索
</button>
)}
</div>
)}
</div>
</div>
</div>
)}
</div>
</div>
)
}
+393
View File
@@ -0,0 +1,393 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Trash2, Plus, Minus, ShoppingBag, ArrowLeft } from 'lucide-react'
interface CartItem {
id: string
quantity: number
component: {
id: string
name: string
brand: string
price: number
imageUrl?: string | null
stock: number
componentType: {
name: string
}
}
}
export default function CartPage() {
const [cartItems, setCartItems] = useState<CartItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isUpdating, setIsUpdating] = useState<string | null>(null)
useEffect(() => {
loadCartItems()
}, [])
const loadCartItems = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
setIsLoading(false)
return
}
const response = await fetch('/api/cart', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const items = await response.json()
setCartItems(items)
} else if (response.status === 401) {
// 登录过期,清除token
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
} catch (error) {
console.error('加载购物车失败:', error)
} finally {
setIsLoading(false)
}
}
const updateQuantity = async (cartItemId: string, newQuantity: number) => {
if (newQuantity < 1) return
setIsUpdating(cartItemId)
try {
const token = localStorage.getItem('token')
const response = await fetch(`/api/cart/${cartItemId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ quantity: newQuantity })
})
if (response.ok) {
// 重新加载购物车
await loadCartItems()
} else {
const data = await response.json()
alert(data.message || '更新失败')
}
} catch (error) {
console.error('更新数量失败:', error)
alert('更新失败')
} finally {
setIsUpdating(null)
}
}
const removeFromCart = async (cartItemId: string) => {
if (!confirm('确定要从购物车中移除这个商品吗?')) return
setIsUpdating(cartItemId)
try {
const token = localStorage.getItem('token')
const response = await fetch(`/api/cart/${cartItemId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
// 重新加载购物车
await loadCartItems()
} else {
const data = await response.json()
alert(data.message || '删除失败')
}
} catch (error) {
console.error('删除失败:', error)
alert('删除失败')
} finally {
setIsUpdating(null)
}
}
const clearCart = async () => {
if (!confirm('确定要清空购物车吗?')) return
try {
const token = localStorage.getItem('token')
const response = await fetch('/api/cart', {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
setCartItems([])
} else {
const data = await response.json()
alert(data.message || '清空失败')
}
} catch (error) {
console.error('清空购物车失败:', error)
alert('清空失败')
}
}
const getTotalPrice = () => {
return cartItems.reduce((total, item) => {
return total + (item.component.price * item.quantity)
}, 0)
}
const getTotalItems = () => {
return cartItems.reduce((total, item) => total + item.quantity, 0)
}
const createOrder = async () => {
if (cartItems.length === 0) {
alert('购物车为空')
return
}
try {
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (!user) {
alert('请先登录')
window.location.href = '/login'
return
}
const orderItems = cartItems.map(item => ({
componentId: item.component.id,
quantity: item.quantity,
price: item.component.price
}))
const response = await fetch('/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token')}`
},
body: JSON.stringify({
items: orderItems,
totalAmount: getTotalPrice()
})
})
if (response.ok) {
// 清空购物车
await clearCart()
alert('订单创建成功!')
window.location.href = '/orders'
} else {
const data = await response.json()
alert(data.message || '订单创建失败')
}
} catch (error) {
console.error('创建订单失败:', error)
alert('订单创建失败,请重试')
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (!user) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<ShoppingBag className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-gray-900 mb-4">请先登录</h2>
<p className="text-gray-600 mb-8">登录后即可查看您的购物车</p>
<Link
href="/login"
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
立即登录
</Link>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Link
href="/components"
className="flex items-center text-blue-600 hover:text-blue-800"
>
<ArrowLeft className="h-5 w-5 mr-2" />
继续购物
</Link>
<h1 className="text-3xl font-bold text-gray-900">购物车</h1>
</div>
{cartItems.length > 0 && (
<button
onClick={clearCart}
className="text-red-600 hover:text-red-800 text-sm font-medium"
>
清空购物车
</button>
)}
</div>
{cartItems.length === 0 ? (
<div className="text-center py-16">
<ShoppingBag className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h2 className="text-2xl font-semibold text-gray-900 mb-4">购物车为空</h2>
<p className="text-gray-600 mb-8">还没有添加任何商品到购物车</p>
<Link
href="/components"
className="bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
开始购物
</Link>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Cart Items */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-sm">
<div className="p-6 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">
商品列表 ({getTotalItems()} 件商品)
</h2>
</div>
<div className="divide-y divide-gray-200">
{cartItems.map((item) => (
<div key={item.id} className="p-6">
<div className="flex items-center space-x-4">
<div className="flex-shrink-0 w-20 h-20 bg-gray-100 rounded-lg overflow-hidden">
{item.component.imageUrl ? (
<img
src={item.component.imageUrl}
alt={item.component.name}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<ShoppingBag className="h-8 w-8 text-gray-400" />
</div>
)}
</div>
<div className="flex-1">
<div className="flex justify-between">
<div>
<h3 className="font-medium text-gray-900">{item.component.name}</h3>
<p className="text-sm text-gray-600">{item.component.brand}</p>
<p className="text-xs text-gray-500">{item.component.componentType.name}</p>
</div>
<div className="text-right">
<p className="font-semibold text-gray-900">¥{item.component.price}</p>
<p className="text-sm text-gray-600">库存: {item.component.stock}</p>
</div>
</div>
<div className="flex items-center justify-between mt-4">
s <div className="flex items-center space-x-2">
<button
onClick={() => updateQuantity(item.id, item.quantity - 1)}
disabled={item.quantity <= 1 || isUpdating === item.id}
className="p-1 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Minus className="h-4 w-4" />
</button>
<span className="w-12 text-center">{item.quantity}</span>
<button
onClick={() => updateQuantity(item.id, item.quantity + 1)}
disabled={item.quantity >= item.component.stock || isUpdating === item.id}
className="p-1 rounded-md hover:bg-gray-100 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="h-4 w-4" />
</button>
</div>
<div className="flex items-center space-x-4">
<p className="font-semibold text-lg">
¥{(item.component.price * item.quantity).toFixed(2)}
</p>
<button
onClick={() => removeFromCart(item.id)}
disabled={isUpdating === item.id}
className="text-red-600 hover:text-red-800 disabled:opacity-50"
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
</div>
</div>
))}
</div>
</div>
</div>
{/* Order Summary */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-sm p-6 sticky top-4">
<h2 className="text-lg font-semibold text-gray-900 mb-4">订单摘要</h2>
<div className="space-y-3 mb-6">
<div className="flex justify-between">
<span className="text-gray-600">商品总数:</span>
<span className="font-medium">{getTotalItems()} 件</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">商品总价:</span>
<span className="font-medium">¥{getTotalPrice().toFixed(2)}</span>
</div>
<div className="border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="font-semibold text-lg">总计:</span>
<span className="font-semibold text-lg text-red-600">
¥{getTotalPrice().toFixed(2)}
</span>
</div>
</div>
</div>
<button
onClick={createOrder}
className="w-full bg-blue-600 text-white py-3 px-4 rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
结算订单
</button>
<p className="text-xs text-gray-500 mt-4 text-center">
点击结算将创建订单并清空购物车
</p>
</div>
</div>
</div>
)}
</div>
</div>
)
}
+255
View File
@@ -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>
)
}
+294
View File
@@ -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>
)
}
+8 -4
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Navbar } from "@/components/Navbar";
const geistSans = Geist({
variable: "--font-geist-sans",
@@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "PC DIY商城 - 电脑配件在线选购",
description: "专业的电脑DIY配件商城,提供CPU、内存、硬盘、主板、显卡、机箱等优质配件",
};
export default function RootLayout({
@@ -23,11 +24,14 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="zh-CN">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Navbar />
<main className="min-h-screen">
{children}
</main>
</body>
</html>
);
+130
View File
@@ -0,0 +1,130 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export default function LoginPage() {
const [formData, setFormData] = useState({
email: '',
password: ''
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
})
const data = await response.json()
if (response.ok) {
localStorage.setItem('token', data.token)
localStorage.setItem('user', JSON.stringify(data.user))
// 触发用户更新事件
window.dispatchEvent(new Event('user-updated'))
router.push('/')
} else {
setError(data.message || '登录失败')
}
} catch (error) {
setError('网络错误,请稍后重试')
} finally {
setIsLoading(false)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
登录您的账户
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
或者{' '}
<Link href="/register" className="font-medium text-blue-600 hover:text-blue-500">
创建新账户
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
邮箱地址
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入邮箱地址"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
密码
</label>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入密码"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{isLoading ? '登录中...' : '登录'}
</button>
</div>
<div className="text-center">
<p className="text-sm text-gray-600">
测试账号:admin@pcdiy.com,密码:admin123
</p>
</div>
</form>
</div>
</div>
)
}
+398
View File
@@ -0,0 +1,398 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import {
ArrowLeft,
Package,
Calendar,
DollarSign,
User,
MapPin,
Phone,
Mail,
Truck,
CheckCircle,
XCircle,
Clock
} from 'lucide-react'
interface OrderItem {
id: string
quantity: number
price: number
component: {
id: string
name: string
brand: string
model: string
imageUrl?: string
componentType: {
name: string
}
}
}
interface Order {
id: string
orderNumber: string
totalAmount: number
status: string
createdAt: string
updatedAt: string
orderItems: OrderItem[]
user: {
username: string
email: string
}
}
const statusMap: { [key: string]: { text: string; color: string; icon: any } } = {
PENDING: { text: '待确认', color: 'bg-yellow-100 text-yellow-800 border-yellow-200', icon: Clock },
CONFIRMED: { text: '已确认', color: 'bg-blue-100 text-blue-800 border-blue-200', icon: CheckCircle },
PROCESSING: { text: '处理中', color: 'bg-purple-100 text-purple-800 border-purple-200', icon: Package },
SHIPPED: { text: '已发货', color: 'bg-green-100 text-green-800 border-green-200', icon: Truck },
DELIVERED: { text: '已送达', color: 'bg-green-100 text-green-800 border-green-200', icon: CheckCircle },
CANCELLED: { text: '已取消', color: 'bg-red-100 text-red-800 border-red-200', icon: XCircle },
}
export default function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const [order, setOrder] = useState<Order | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isCancelling, setIsCancelling] = useState(false)
const [orderId, setOrderId] = useState<string>('')
const router = useRouter()
useEffect(() => {
const getParams = async () => {
const { id } = await params
setOrderId(id)
}
getParams()
}, [params])
useEffect(() => {
if (orderId) {
loadOrderDetail()
}
}, [orderId])
const loadOrderDetail = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
router.push('/login')
return
}
const response = await fetch(`/api/orders/${(await params).id}`, {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const data = await response.json()
setOrder(data)
} else if (response.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
router.push('/login')
} else if (response.status === 404) {
router.push('/orders')
}
} catch (error) {
console.error('加载订单详情失败:', error)
} finally {
setIsLoading(false)
}
}
const handleCancelOrder = async () => {
if (!order || order.status !== 'PENDING') {
alert('当前订单状态不允许取消')
return
}
if (!confirm('确定要取消这个订单吗?取消后将恢复商品库存。')) {
return
}
setIsCancelling(true)
try {
const token = localStorage.getItem('token')
const response = await fetch(`/api/orders/${orderId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ action: 'cancel' })
})
if (response.ok) {
const updatedOrder = await response.json()
setOrder(updatedOrder)
alert('订单已成功取消')
} else {
const error = await response.json()
alert(error.message || '取消订单失败')
}
} catch (error) {
console.error('取消订单失败:', error)
alert('取消订单失败,请重试')
} finally {
setIsCancelling(false)
}
}
const handleReorder = async () => {
if (!order) return
const token = localStorage.getItem('token')
if (!token) {
alert('请先登录')
return
}
try {
// 批量添加订单商品到购物车
for (const item of order.orderItems) {
await fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
componentId: item.component.id,
quantity: item.quantity
})
})
}
window.dispatchEvent(new Event('cart-updated'))
alert('商品已添加到购物车!')
router.push('/cart')
} catch (error) {
console.error('添加到购物车失败:', error)
alert('添加失败,请重试')
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
if (!order) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Package className="h-24 w-24 text-gray-300 mx-auto mb-6" />
<h2 className="text-2xl font-semibold text-gray-900 mb-4">订单不存在</h2>
<Link
href="/orders"
className="inline-flex items-center bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
<ArrowLeft className="h-5 w-5 mr-2" />
返回订单列表
</Link>
</div>
</div>
)
}
const statusConfig = statusMap[order.status] || { text: order.status, color: 'bg-gray-100 text-gray-800 border-gray-200', icon: Package }
const StatusIcon = statusConfig.icon
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div className="flex items-center space-x-4">
<Link
href="/orders"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
<ArrowLeft className="h-6 w-6" />
</Link>
<div>
<h1 className="text-3xl font-bold text-gray-900">订单详情</h1>
<p className="text-gray-600">订单号: {order.orderNumber}</p>
</div>
</div>
</div>
<div className="space-y-6">
{/* Order Status */}
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className={`p-3 rounded-full ${statusConfig.color.replace('text-', 'bg-').replace('bg-', 'bg-').replace('-100', '-200')}`}>
<StatusIcon className="h-6 w-6" />
</div>
<div>
<h2 className="text-xl font-semibold text-gray-900">订单状态</h2>
<span className={`inline-flex px-3 py-1 text-sm font-medium rounded-full border ${statusConfig.color}`}>
{statusConfig.text}
</span>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">下单时间</p>
<p className="font-medium text-gray-900">
{new Date(order.createdAt).toLocaleString('zh-CN')}
</p>
{order.updatedAt !== order.createdAt && (
<>
<p className="text-sm text-gray-600 mt-2">更新时间</p>
<p className="font-medium text-gray-900">
{new Date(order.updatedAt).toLocaleString('zh-CN')}
</p>
</>
)}
</div>
</div>
</div>
{/* Order Items */}
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">订单商品</h3>
</div>
<div className="p-6">
<div className="space-y-6">
{order.orderItems.map((item, index) => (
<div key={item.id} className={`flex items-center space-x-4 ${index > 0 ? 'pt-6 border-t border-gray-200' : ''}`}>
<div className="w-20 h-20 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
{item.component?.imageUrl ? (
<img loading='lazy'
src={item.component.imageUrl}
alt={item.component?.name || '商品图片'}
className="max-w-full max-h-full object-contain"
/>
) : (
<Package className="h-10 w-10 text-gray-400" />
)}
</div>
<div className="flex-1">
<h4 className="font-medium text-gray-900 text-lg">{item.component?.name || '未知商品'}</h4>
<div className="mt-1 space-y-1">
<p className="text-sm text-gray-600">
品牌: {item.component?.brand || '未知品牌'}
</p>
<p className="text-sm text-gray-600">
型号: {item.component?.model || '未知型号'}
</p>
<p className="text-sm text-gray-600">
类型: {item.component?.componentType?.name || '未知类型'}
</p>
</div>
</div>
<div className="text-right">
<p className="text-lg font-medium text-gray-900">¥{item.price}</p>
<p className="text-sm text-gray-600">数量: {item.quantity}</p>
<p className="text-sm font-medium text-red-600">
小计: ¥{(item.price * item.quantity).toFixed(2)}
</p>
</div>
</div>
))}
</div>
</div>
</div>
{/* Order Summary */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">订单汇总</h3>
<div className="space-y-3">
<div className="flex justify-between text-sm">
<span className="text-gray-600">商品数量:</span>
<span className="text-gray-900">{order.orderItems.length} 件</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">商品总价:</span>
<span className="text-gray-900">¥{order.totalAmount}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">运费:</span>
<span className="text-gray-900">免费</span>
</div>
<div className="border-t border-gray-200 pt-3">
<div className="flex justify-between">
<span className="text-lg font-semibold text-gray-900">实付款:</span>
<span className="text-xl font-bold text-red-600">¥{order.totalAmount}</span>
</div>
</div>
</div>
</div>
{/* User Info */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">用户信息</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-center space-x-3">
<User className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm text-gray-600">用户名</p>
<p className="font-medium text-gray-900">{order.user.username}</p>
</div>
</div>
<div className="flex items-center space-x-3">
<Mail className="h-5 w-5 text-gray-400" />
<div>
<p className="text-sm text-gray-600">邮箱</p>
<p className="font-medium text-gray-900">{order.user.email}</p>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
<Calendar className="h-4 w-4 inline mr-1" />
订单创建于 {new Date(order.createdAt).toLocaleDateString('zh-CN')}
</div>
<div className="flex space-x-3">
{order.status === 'PENDING' && (
<button
onClick={handleCancelOrder}
disabled={isCancelling}
className="bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 disabled:bg-red-300 disabled:cursor-not-allowed transition-colors"
>
{isCancelling ? '取消中...' : '取消订单'}
</button>
)}
{order.status === 'DELIVERED' && (
<button
onClick={handleReorder}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
再次购买
</button>
)}
<Link
href="/orders"
className="bg-gray-100 text-gray-700 px-4 py-2 rounded-lg hover:bg-gray-200 transition-colors"
>
返回订单列表
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
+334
View File
@@ -0,0 +1,334 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { Package, Calendar, DollarSign, Eye, Filter } from 'lucide-react'
interface OrderItem {
id: string
quantity: number
price: number
component: {
id: string
name: string
brand: string
imageUrl?: string
componentType: {
name: string
}
}
}
interface Order {
id: string
orderNumber: string
totalAmount: number
status: string
createdAt: string
orderItems: OrderItem[]
}
const statusMap: { [key: string]: { text: string; color: string } } = {
PENDING: { text: '待确认', color: 'bg-yellow-100 text-yellow-800' },
CONFIRMED: { text: '已确认', color: 'bg-blue-100 text-blue-800' },
PROCESSING: { text: '处理中', color: 'bg-purple-100 text-purple-800' },
SHIPPED: { text: '已发货', color: 'bg-green-100 text-green-800' },
DELIVERED: { text: '已送达', color: 'bg-green-100 text-green-800' },
CANCELLED: { text: '已取消', color: 'bg-red-100 text-red-800' },
}
export default function OrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedStatus, setSelectedStatus] = useState<string>('ALL')
const [cancellingOrderId, setCancellingOrderId] = useState<string | null>(null)
useEffect(() => {
loadOrders()
}, [])
const loadOrders = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
const response = await fetch('/api/orders', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const data = await response.json()
setOrders(data)
} else if (response.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
} catch (error) {
console.error('加载订单失败:', error)
} finally {
setIsLoading(false)
}
}
const filteredOrders = selectedStatus === 'ALL'
? orders
: orders.filter(order => order.status === selectedStatus)
const handleCancelOrder = async (orderId: string) => {
if (!confirm('确定要取消这个订单吗?取消后将恢复商品库存。')) {
return
}
setCancellingOrderId(orderId)
try {
const token = localStorage.getItem('token')
const response = await fetch(`/api/orders/${orderId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ action: 'cancel' })
})
if (response.ok) {
// 重新加载订单列表
await loadOrders()
alert('订单已成功取消')
} else {
const error = await response.json()
alert(error.message || '取消订单失败')
}
} catch (error) {
console.error('取消订单失败:', error)
alert('取消订单失败,请重试')
} finally {
setCancellingOrderId(null)
} }
const handleReorder = async (order: Order) => {
const token = localStorage.getItem('token')
if (!token) {
alert('请先登录')
return
}
try {
// 批量添加订单商品到购物车
for (const item of order.orderItems) {
await fetch('/api/cart', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
componentId: item.component.id,
quantity: item.quantity
})
})
}
window.dispatchEvent(new Event('cart-updated'))
alert('商品已添加到购物车!')
window.location.href = '/cart'
} catch (error) {
console.error('添加到购物车失败:', error)
alert('添加失败,请重试')
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
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">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<h1 className="text-3xl font-bold text-gray-900">我的订单</h1>
<Link
href="/components"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
继续购物
</Link>
</div>
{/* Status Filter */}
<div className="bg-white rounded-lg shadow-sm p-4 mb-6">
<div className="flex items-center space-x-4">
<Filter className="h-5 w-5 text-gray-600" />
<span className="text-gray-700">筛选:</span>
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedStatus('ALL')}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
selectedStatus === 'ALL'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
全部
</button>
{Object.entries(statusMap).map(([status, config]) => (
<button
key={status}
onClick={() => setSelectedStatus(status)}
className={`px-3 py-1 rounded-full text-sm transition-colors ${
selectedStatus === status
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{config.text}
</button>
))}
</div>
</div>
</div>
{/* Orders List */}
{filteredOrders.length === 0 ? (
<div className="text-center py-16">
<Package className="h-24 w-24 text-gray-300 mx-auto mb-6" />
<h2 className="text-2xl font-semibold text-gray-900 mb-4">
{selectedStatus === 'ALL' ? '暂无订单' : '暂无该状态的订单'}
</h2>
<p className="text-gray-600 mb-8">
{selectedStatus === 'ALL'
? '还没有任何订单,去看看有什么好东西吧!'
: '可以试试其他状态筛选'
}
</p>
<Link
href="/components"
className="inline-flex items-center bg-blue-600 text-white px-6 py-3 rounded-lg hover:bg-blue-700 transition-colors"
>
<Package className="h-5 w-5 mr-2" />
去购物
</Link>
</div>
) : (
<div className="space-y-6">
{filteredOrders.map((order) => (
<div key={order.id} className="bg-white rounded-lg shadow-sm overflow-hidden">
{/* Order Header */}
<div className="bg-gray-50 px-6 py-4 border-b border-gray-200">
<div className="flex justify-between items-center">
<div className="flex items-center space-x-6">
<div>
<p className="text-sm text-gray-600">订单号</p>
<p className="font-medium text-gray-900">{order.orderNumber}</p>
</div>
<div>
<p className="text-sm text-gray-600">下单时间</p>
<p className="font-medium text-gray-900">
{new Date(order.createdAt).toLocaleString('zh-CN')}
</p>
</div>
<div>
<p className="text-sm text-gray-600">订单状态</p>
<span className={`inline-flex px-2 py-1 text-xs font-medium rounded-full ${
statusMap[order.status]?.color || 'bg-gray-100 text-gray-800'
}`}>
{statusMap[order.status]?.text || order.status}
</span>
</div>
</div>
<div className="text-right">
<p className="text-sm text-gray-600">订单金额</p>
<p className="text-xl font-bold text-red-600">¥{order.totalAmount.toFixed(2)}</p>
</div>
</div>
</div>
{/* Order Items */}
<div className="p-6">
<div className="space-y-4">
{order.orderItems.map((item) => (
<div key={item.id} className="flex items-center space-x-4"> <div className="w-16 h-16 bg-gray-100 rounded-lg flex items-center justify-center flex-shrink-0">
{item.component?.imageUrl ? (
<img loading='lazy'
src={item.component.imageUrl}
alt={item.component?.name || '商品图片'}
className="max-w-full max-h-full object-contain"
/>
) : (
<Package className="h-8 w-8 text-gray-400" />
)}
</div><div className="flex-1">
<h3 className="font-medium text-gray-900">{item.component?.name || '未知商品'}</h3>
<p className="text-sm text-gray-600">
{item.component?.brand || '未知品牌'} | {item.component?.componentType?.name || '未知类型'}
</p>
<p className="text-sm text-gray-600">数量: {item.quantity}</p>
</div>
<div className="text-right">
<p className="font-medium text-gray-900">¥{item.price}</p>
<p className="text-sm text-gray-600">
小计: ¥{(item.price * item.quantity).toFixed(2)}
</p>
</div>
</div>
))}
</div>
{/* Order Actions */}
<div className="flex justify-between items-center mt-6 pt-6 border-t border-gray-200">
<div className="flex items-center space-x-2 text-sm text-gray-600">
<Calendar className="h-4 w-4" />
<span>共 {order.orderItems.length} 件商品</span>
<DollarSign className="h-4 w-4 ml-4" />
<span>实付款: ¥{order.totalAmount}</span>
</div>
<div className="flex space-x-3">
<Link
href={`/orders/${order.id}`}
className="inline-flex items-center text-blue-600 hover:text-blue-800 text-sm"
>
<Eye className="h-4 w-4 mr-1" />
查看详情
</Link> {order.status === 'PENDING' && (
<button
onClick={() => handleCancelOrder(order.id)}
disabled={cancellingOrderId === order.id}
className="text-red-600 hover:text-red-800 text-sm disabled:text-red-300 disabled:cursor-not-allowed"
>
{cancellingOrderId === order.id ? '取消中...' : '取消订单'}
</button>
)}
{order.status === 'DELIVERED' && (
<button
onClick={() => handleReorder(order)}
className="text-blue-600 hover:text-blue-800 text-sm"
>
再次购买
</button>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
+121 -98
View File
@@ -1,103 +1,126 @@
import Image from "next/image";
import Link from 'next/link'
import { prisma } from '@/lib/prisma'
import { ComponentCard } from '@/components/ComponentCard'
export default async function Home() {
// 获取每种配件类型的前几个商品
const componentTypes = await prisma.componentType.findMany({
include: {
components: {
take: 6,
orderBy: {
createdAt: 'desc'
}
}
}
})
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<div className="min-h-screen bg-gray-50">
{/* Hero Section */}
<section className="bg-gradient-to-r from-blue-600 to-purple-600 text-white py-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 className="text-4xl md:text-6xl font-bold mb-6">
欢迎来到PC DIY商城
</h1>
<p className="text-xl md:text-2xl mb-8">
专业的电脑配件在线选购平台,让您轻松打造梦想主机
</p>
<div className="space-x-4">
<Link
href="/components"
className="bg-white text-blue-600 px-8 py-3 rounded-lg font-semibold hover:bg-gray-100 transition-colors"
>
立即选购
</Link>
<Link
href="/build"
className="border-2 border-white text-white px-8 py-3 rounded-lg font-semibold hover:bg-white hover:text-blue-600 transition-colors"
>
在线装机
</Link>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org →
</a>
</footer>
</section>
{/* Features Section */}
<section className="py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-center mb-12">为什么选择我们</h2>
<div className="grid md:grid-cols-3 gap-8">
<div className="text-center">
<div className="bg-blue-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">🔧</span>
</div>
<h3 className="text-xl font-semibold mb-2">专业配件</h3>
<p className="text-gray-600">
提供CPU、内存、硬盘、主板、显卡、机箱等全系列配件
</p>
</div>
<div className="text-center">
<div className="bg-green-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">💰</span>
</div>
<h3 className="text-xl font-semibold mb-2">价格优势</h3>
<p className="text-gray-600">
多品牌选择,不同价位满足各种预算需求
</p>
</div>
<div className="text-center">
<div className="bg-purple-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<span className="text-2xl">🚀</span>
</div>
<h3 className="text-xl font-semibold mb-2">在线装机</h3>
<p className="text-gray-600">
智能配置推荐,一站式完成整机搭配
</p>
</div>
</div>
</div>
</section>
{/* Product Categories */}
<section className="py-16 bg-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 className="text-3xl font-bold text-center mb-12">热门配件</h2>
{componentTypes.map((type) => (
<div key={type.id} className="mb-12">
<div className="flex justify-between items-center mb-6">
<h3 className="text-2xl font-semibold">{type.name}</h3>
<Link
href={`/components?type=${type.id}`}
className="text-blue-600 hover:text-blue-800"
>
查看更多 →
</Link>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{type.components.map((component) => (
<ComponentCard key={component.id} component={component} />
))}
</div>
</div>
))}
</div>
</section>
{/* CTA Section */}
<section className="py-16 bg-gray-800 text-white">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 className="text-3xl font-bold mb-4">准备开始装机了吗?</h2>
<p className="text-xl mb-8">
注册账号,享受更多服务和优惠
</p>
<Link
href="/register"
className="bg-blue-600 text-white px-8 py-3 rounded-lg font-semibold hover:bg-blue-700 transition-colors"
>
立即注册
</Link>
</div>
</section>
</div>
);
)
}
+566
View File
@@ -0,0 +1,566 @@
'use client'
import { useState, useEffect } from 'react'
import { User, Mail, Phone, MapPin, Save, Edit, Key } from 'lucide-react'
interface UserProfile {
id: string
email: string
username: string
name?: string
phone?: string
address?: string
}
interface UserStats {
user: {
id: string
username: string
email: string
memberSince: string
}
orderStats: {
total: number
byStatus: {
PENDING: number
CONFIRMED: number
SHIPPED: number
DELIVERED: number
CANCELLED: number
}
totalSpent: number
}
recentOrders: Array<{
id: string
status: string
totalAmount: number
createdAt: string
}>
favoriteComponentType: string | null
summary: {
totalOrders: number
totalSpent: number
pendingOrders: number
completedOrders: number
cartItems: number
}
}
export default function ProfilePage() {
const [user, setUser] = useState<UserProfile | null>(null)
const [userStats, setUserStats] = useState<UserStats | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [formData, setFormData] = useState({
name: '',
phone: '',
address: ''
})
const [passwordForm, setPasswordForm] = useState({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
const [showPasswordForm, setShowPasswordForm] = useState(false)
useEffect(() => {
loadUserProfile()
loadUserStats()
}, [])
const loadUserProfile = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
window.location.href = '/login'
return
}
const response = await fetch('/api/user/profile', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const userData = await response.json()
setUser(userData)
setFormData({
name: userData.name || '',
phone: userData.phone || '',
address: userData.address || ''
})
} else if (response.status === 401) {
localStorage.removeItem('token')
localStorage.removeItem('user')
window.location.href = '/login'
}
} catch (error) {
console.error('加载用户资料失败:', error)
} finally {
setIsLoading(false)
}
}
const loadUserStats = async () => {
try {
const token = localStorage.getItem('token')
if (!token) {
return
}
const response = await fetch('/api/user/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const stats = await response.json()
setUserStats(stats)
}
} catch (error) {
console.error('加载用户统计失败:', error)
}
}
const handleSaveProfile = async () => {
setIsSaving(true)
try {
const token = localStorage.getItem('token')
const response = await fetch('/api/user/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(formData)
})
if (response.ok) {
const updatedUser = await response.json()
setUser(updatedUser)
setIsEditing(false)
// 更新localStorage中的用户信息
localStorage.setItem('user', JSON.stringify(updatedUser))
alert('资料更新成功!')
} else {
alert('更新失败,请重试')
}
} catch (error) {
console.error('更新用户资料失败:', error)
alert('更新失败,请重试')
} finally {
setIsSaving(false)
}
}
const handleChangePassword = async () => {
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
alert('新密码和确认密码不匹配')
return
}
if (passwordForm.newPassword.length < 6) {
alert('新密码长度至少6位')
return
}
try {
const token = localStorage.getItem('token')
const response = await fetch('/api/user/change-password', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
currentPassword: passwordForm.currentPassword,
newPassword: passwordForm.newPassword
})
})
if (response.ok) {
alert('密码修改成功!')
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
setShowPasswordForm(false)
} else {
const error = await response.json()
alert(error.message || '密码修改失败')
}
} catch (error) {
console.error('修改密码失败:', error)
alert('修改失败,请重试')
}
}
const handleCancel = () => {
setFormData({
name: user?.name || '',
phone: user?.phone || '',
address: user?.address || ''
})
setIsEditing(false)
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="text-center mb-8">
<div className="w-24 h-24 bg-blue-600 rounded-full flex items-center justify-center mx-auto mb-4">
<User className="h-12 w-12 text-white" />
</div>
<h1 className="text-3xl font-bold text-gray-900">{user?.name || user?.username}</h1>
<p className="text-gray-600">{user?.email}</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Profile Info */}
<div className="lg:col-span-2">
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">个人资料</h2>
{!isEditing ? (
<button
onClick={() => setIsEditing(true)}
className="inline-flex items-center text-blue-600 hover:text-blue-800"
>
<Edit className="h-4 w-4 mr-2" />
编辑资料
</button>
) : (
<div className="flex space-x-2">
<button
onClick={handleSaveProfile}
disabled={isSaving}
className="inline-flex items-center bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
>
<Save className="h-4 w-4 mr-2" />
{isSaving ? '保存中...' : '保存'}
</button>
<button
onClick={handleCancel}
className="text-gray-600 hover:text-gray-800 px-4 py-2"
>
取消
</button>
</div>
)}
</div>
<div className="space-y-6">
{/* Email (readonly) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Mail className="h-4 w-4 inline mr-2" />
邮箱地址
</label>
<input
type="email"
value={user?.email || ''}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
/>
<p className="text-xs text-gray-500 mt-1">邮箱地址不可修改</p>
</div>
{/* Username (readonly) */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<User className="h-4 w-4 inline mr-2" />
用户名
</label>
<input
type="text"
value={user?.username || ''}
disabled
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-50 text-gray-500"
/>
<p className="text-xs text-gray-500 mt-1">用户名不可修改</p>
</div>
{/* Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
姓名
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
disabled={!isEditing}
className={`w-full px-3 py-2 border border-gray-300 rounded-lg ${
isEditing ? 'focus:ring-2 focus:ring-blue-500 focus:border-blue-500' : 'bg-gray-50'
}`}
placeholder="请输入真实姓名"
/>
</div>
{/* Phone */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<Phone className="h-4 w-4 inline mr-2" />
手机号码
</label>
<input
type="tel"
value={formData.phone}
onChange={(e) => setFormData({...formData, phone: e.target.value})}
disabled={!isEditing}
className={`w-full px-3 py-2 border border-gray-300 rounded-lg ${
isEditing ? 'focus:ring-2 focus:ring-blue-500 focus:border-blue-500' : 'bg-gray-50'
}`}
placeholder="请输入手机号码"
/>
</div>
{/* Address */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
<MapPin className="h-4 w-4 inline mr-2" />
收货地址
</label>
<textarea
value={formData.address}
onChange={(e) => setFormData({...formData, address: e.target.value})}
disabled={!isEditing}
rows={3}
className={`w-full px-3 py-2 border border-gray-300 rounded-lg ${
isEditing ? 'focus:ring-2 focus:ring-blue-500 focus:border-blue-500' : 'bg-gray-50'
}`}
placeholder="请输入详细地址"
/>
</div>
</div>
</div>
{/* Password Change */}
<div className="bg-white rounded-lg shadow-sm p-6 mt-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-gray-900">安全设置</h2>
<button
onClick={() => setShowPasswordForm(!showPasswordForm)}
className="inline-flex items-center text-blue-600 hover:text-blue-800"
>
<Key className="h-4 w-4 mr-2" />
修改密码
</button>
</div>
{showPasswordForm && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
当前密码
</label>
<input
type="password"
value={passwordForm.currentPassword}
onChange={(e) => setPasswordForm({...passwordForm, currentPassword: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入当前密码"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
新密码
</label>
<input
type="password"
value={passwordForm.newPassword}
onChange={(e) => setPasswordForm({...passwordForm, newPassword: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入新密码(至少6位)"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
确认新密码
</label>
<input
type="password"
value={passwordForm.confirmPassword}
onChange={(e) => setPasswordForm({...passwordForm, confirmPassword: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="请再次输入新密码"
/>
</div>
<div className="flex space-x-3">
<button
onClick={handleChangePassword}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
>
修改密码
</button>
<button
onClick={() => {
setShowPasswordForm(false)
setPasswordForm({
currentPassword: '',
newPassword: '',
confirmPassword: ''
})
}}
className="text-gray-600 hover:text-gray-800 px-4 py-2"
>
取消
</button>
</div>
</div>
)}
</div>
</div>
{/* Quick Actions */}
<div className="lg:col-span-1">
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">快捷操作</h3>
<div className="space-y-3">
<a
href="/orders"
className="block w-full text-left px-4 py-3 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
查看我的订单
</a>
<a
href="/cart"
className="block w-full text-left px-4 py-3 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
查看购物车
</a>
<a
href="/build"
className="block w-full text-left px-4 py-3 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
装机配置
</a>
<a
href="/components"
className="block w-full text-left px-4 py-3 text-gray-700 hover:bg-gray-50 rounded-lg transition-colors"
>
浏览商品
</a>
</div>
</div> {/* Account Stats */}
<div className="bg-white rounded-lg shadow-sm p-6 mt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">账户统计</h3>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600">注册时间:</span>
<span className="font-medium text-gray-900">
{userStats?.user?.memberSince ?
new Date(userStats.user.memberSince).toLocaleDateString('zh-CN') :
'--'
}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">订单数量:</span>
<span className="font-medium text-gray-900">
{userStats?.summary?.totalOrders || 0}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">累计消费:</span>
<span className="font-medium text-red-600">
¥{userStats?.summary?.totalSpent?.toFixed(2) || '0.00'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">待处理订单:</span>
<span className="font-medium text-orange-600">
{userStats?.summary?.pendingOrders || 0}
</span>
</div> <div className="flex justify-between">
<span className="text-gray-600">已完成订单:</span>
<span className="font-medium text-green-600">
{userStats?.summary?.completedOrders || 0}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">购物车商品:</span>
<span className="font-medium text-blue-600">
{userStats?.summary?.cartItems || 0}
</span>
</div>
{userStats?.favoriteComponentType && (
<div className="flex justify-between">
<span className="text-gray-600">偏好配件:</span>
<span className="font-medium text-blue-600">
{userStats.favoriteComponentType}
</span>
</div> )}
</div>
</div>
{/* Recent Orders */}
{userStats?.recentOrders && userStats.recentOrders.length > 0 && (
<div className="bg-white rounded-lg shadow-sm p-6 mt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">最近订单</h3>
<div className="space-y-3">
{userStats.recentOrders.map((order) => (
<div key={order.id} className="flex justify-between items-center p-3 border border-gray-200 rounded-lg">
<div>
<p className="font-medium text-gray-900">订单 #{order.id.slice(-8)}</p>
<p className="text-sm text-gray-500">
{new Date(order.createdAt).toLocaleDateString('zh-CN')}
</p>
</div>
<div className="text-right">
<p className="font-medium text-red-600">¥{order.totalAmount.toFixed(2)}</p>
<span className={`text-xs px-2 py-1 rounded-full ${
order.status === 'PENDING' ? 'bg-yellow-100 text-yellow-800' :
order.status === 'CONFIRMED' ? 'bg-blue-100 text-blue-800' :
order.status === 'SHIPPED' ? 'bg-purple-100 text-purple-800' :
order.status === 'PROCESSING' ? 'bg-purple-100 text-purple-800' :
order.status === 'DELIVERED' ? 'bg-green-100 text-green-800' :
'bg-red-100 text-red-800'
}`}>
{order.status === 'PENDING' ? '待确认' :
order.status === 'CONFIRMED' ? '已确认' :
order.status === 'SHIPPED' ? '已发货' :
order.status === 'DELIVERED' ? '已送达' :
order.status === 'PROCESSING' ? '处理中' :
'已取消'}
</span>
</div>
</div>
))}
<div className="text-center">
<a
href="/orders"
className="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
查看全部订单 →
</a>
</div>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}
+215
View File
@@ -0,0 +1,215 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export default function RegisterPage() {
const [formData, setFormData] = useState({
email: '',
username: '',
password: '',
confirmPassword: '',
name: '',
phone: '',
address: ''
})
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const router = useRouter()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
setError('')
if (formData.password !== formData.confirmPassword) {
setError('两次输入的密码不一致')
setIsLoading(false)
return
}
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email: formData.email,
username: formData.username,
password: formData.password,
name: formData.name,
phone: formData.phone,
address: formData.address
}),
})
const data = await response.json()
if (response.ok) {
router.push('/login?message=注册成功,请登录')
} else {
setError(data.message || '注册失败')
}
} catch (error) {
setError('网络错误,请稍后重试')
} finally {
setIsLoading(false)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
})
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div className="max-w-md w-full space-y-8">
<div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
创建您的账户
</h2>
<p className="mt-2 text-center text-sm text-gray-600">
已有账户?{' '}
<Link href="/login" className="font-medium text-blue-600 hover:text-blue-500">
立即登录
</Link>
</p>
</div>
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
{error && (
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
邮箱地址 *
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入邮箱地址"
/>
</div>
<div>
<label htmlFor="username" className="block text-sm font-medium text-gray-700">
用户名 *
</label>
<input
id="username"
name="username"
type="text"
required
value={formData.username}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入用户名"
/>
</div>
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
真实姓名
</label>
<input
id="name"
name="name"
type="text"
value={formData.name}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入真实姓名"
/>
</div>
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700">
联系电话
</label>
<input
id="phone"
name="phone"
type="tel"
value={formData.phone}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入联系电话"
/>
</div>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
收货地址
</label>
<input
id="address"
name="address"
type="text"
value={formData.address}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入收货地址"
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
密码 *
</label>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请输入密码"
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700">
确认密码 *
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
value={formData.confirmPassword}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="请再次输入密码"
/>
</div>
</div>
<div>
<button
type="submit"
disabled={isLoading}
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
{isLoading ? '注册中...' : '注册'}
</button>
</div>
</form>
</div>
</div>
)
}