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