This commit is contained in:
2025-06-22 11:34:32 +08:00
parent 142992a6c9
commit 7d139b22db
55 changed files with 11035 additions and 105 deletions
+727
View File
@@ -0,0 +1,727 @@
'use client'
import { useState, useEffect } from 'react'
import { withAdminAuth } from '@/components/admin/AdminAuth'
import { Plus, Edit, Trash2, Search, Upload, Download, FileText } from 'lucide-react'
interface Component {
id: string
name: string
brand: string
model: string
price: number
description?: string
imageUrl?: string
stock: number
specifications?: string
componentType: {
id: string
name: string
}
}
interface ComponentType {
id: string
name: string
}
function AdminComponentsPage() {
const [components, setComponents] = useState<Component[]>([])
const [componentTypes, setComponentTypes] = useState<ComponentType[]>([])
const [isLoading, setIsLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [showBatchModal, setShowBatchModal] = useState(false)
const [editingComponent, setEditingComponent] = useState<Component | null>(null)
const [searchTerm, setSearchTerm] = useState('')
const [batchFile, setBatchFile] = useState<File | null>(null)
const [batchData, setBatchData] = useState<any[]>([])
const [batchImportLoading, setBatchImportLoading] = useState(false)
const [validationErrors, setValidationErrors] = useState<string[]>([])
const [validData, setValidData] = useState<any[]>([])
const [invalidData, setInvalidData] = useState<any[]>([])
const [formData, setFormData] = useState({
name: '',
brand: '',
model: '',
price: '',
description: '',
imageUrl: '',
stock: '',
componentTypeId: '',
specifications: ''
})
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const [componentsRes, typesRes] = await Promise.all([
fetch('/api/components?limit=100'),
fetch('/api/component-types')
])
if (componentsRes.ok) {
const data = await componentsRes.json()
setComponents(data.components)
}
if (typesRes.ok) {
const types = await typesRes.json()
setComponentTypes(types)
}
} catch (error) {
console.error('加载数据失败:', error)
} finally {
setIsLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
try {
const url = editingComponent
? `/api/components/${editingComponent.id}`
: '/api/components'
const method = editingComponent ? 'PUT' : 'POST'
const response = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
})
if (response.ok) {
await loadData()
setShowModal(false)
resetForm()
} else {
const data = await response.json()
alert(data.message || '操作失败')
}
} catch (error) {
console.error('提交失败:', error)
alert('操作失败')
}
}
const handleDelete = async (id: string) => {
if (!confirm('确定要删除这个配件吗?')) return
try {
const response = await fetch(`/api/components/${id}`, {
method: 'DELETE',
})
if (response.ok) {
await loadData()
} else {
alert('删除失败')
}
} catch (error) {
console.error('删除失败:', error)
alert('删除失败')
}
}
const handleEdit = (component: Component) => {
setEditingComponent(component)
setFormData({
name: component.name,
brand: component.brand,
model: component.model,
price: component.price.toString(),
description: component.description || '',
imageUrl: component.imageUrl || '',
stock: component.stock.toString(),
componentTypeId: component.componentType.id,
specifications: component.specifications || ''
})
setShowModal(true)
}
const resetForm = () => {
setFormData({
name: '',
brand: '',
model: '',
price: '',
description: '',
imageUrl: '',
stock: '',
componentTypeId: '',
specifications: ''
})
setEditingComponent(null)
}
// 批量导入相关函数
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) {
setBatchFile(file)
parseFile(file)
}
}
const parseFile = async (file: File) => {
const text = await file.text()
try {
let data: any[] = []
if (file.name.endsWith('.json')) {
const parsed = JSON.parse(text)
data = Array.isArray(parsed) ? parsed : [parsed]
} else if (file.name.endsWith('.csv')) {
const lines = text.split('\n')
if (lines.length < 2) {
alert('CSV文件格式错误')
return
}
const headers = lines[0].split(',').map(h => h.trim())
data = lines.slice(1)
.filter(line => line.trim())
.map(line => {
const values = line.split(',').map(v => v.trim().replace(/^"|"$/g, ''))
const obj: any = {}
headers.forEach((header, index) => {
obj[header] = values[index] || ''
})
return obj
})
}
setBatchData(data)
validateBatchData(data)
} catch (error) {
alert('文件解析失败,请检查文件格式')
console.error('File parse error:', error)
}
}
const validateBatchData = (data: any[]) => {
const errors: string[] = []
const valid: any[] = []
const invalid: any[] = []
data.forEach((item, index) => {
const itemErrors: string[] = []
// 验证必需字段
if (!item.name || typeof item.name !== 'string' || item.name.trim() === '') {
itemErrors.push('缺少商品名称')
}
if (!item.brand || typeof item.brand !== 'string' || item.brand.trim() === '') {
itemErrors.push('缺少品牌')
}
if (!item.model || typeof item.model !== 'string' || item.model.trim() === '') {
itemErrors.push('缺少型号')
}
// 验证价格
const price = typeof item.price === 'string' ? parseFloat(item.price) : item.price
if (isNaN(price) || price < 0) {
itemErrors.push('价格必须是有效的正数')
}
// 验证库存
const stock = typeof item.stock === 'string' ? parseInt(item.stock) : item.stock
if (isNaN(stock) || stock < 0) {
itemErrors.push('库存必须是有效的非负整数')
}
// 验证配件类型
const hasTypeId = item.componentTypeId && item.componentTypeId.trim() !== ''
const hasTypeName = (item.typeName || item.type) && (item.typeName || item.type).trim() !== ''
if (!hasTypeId && !hasTypeName) {
itemErrors.push('缺少配件类型信息 (componentTypeId, typeName 或 type)')
}
if (itemErrors.length > 0) {
invalid.push({ ...item, _errors: itemErrors, _index: index + 1 })
errors.push(`第${index + 1}行: ${itemErrors.join(', ')}`)
} else {
valid.push(item)
}
})
setValidationErrors(errors)
setValidData(valid)
setInvalidData(invalid)
}
const handleBatchImport = async () => {
if (validData.length === 0) {
alert('没有有效的数据可导入')
return
}
if (invalidData.length > 0) {
const proceed = confirm(`发现${invalidData.length}行无效数据,是否只导入${validData.length}行有效数据?`)
if (!proceed) return
}
setBatchImportLoading(true)
try {
const response = await fetch('/api/components/batch', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
components: validData
}),
})
const result = await response.json()
if (response.ok && result.success) {
const { summary, results } = result
// 显示详细的导入结果
const failedItems = results.filter((r: any) => !r.success)
let message = `导入完成!\n成功: ${summary.successful}\n失败: ${summary.failed}`
if (summary.newTypesCreated > 0) {
message += `\n新创建配件类型: ${summary.newTypesCreated}`
}
if (failedItems.length > 0 && failedItems.length <= 5) {
message += '\n\n失败项目:'
failedItems.forEach((item: any, index: number) => {
message += `\n${index + 1}. ${item.item.name || '未知'}: ${item.error}`
})
} else if (failedItems.length > 5) {
message += `\n\n失败项目过多,请检查数据格式或查看控制台详情`
console.log('批量导入失败项目:', failedItems)
}
alert(message)
if (summary.successful > 0) {
await loadData()
setShowBatchModal(false)
setBatchFile(null)
setBatchData([])
setValidData([])
setInvalidData([])
setValidationErrors([])
}
} else {
alert(`批量导入失败: ${result.message || '未知错误'}`)
console.error('Batch import error:', result)
}
} catch (error) {
alert('批量导入失败:网络或服务器错误')
console.error('Batch import error:', error)
} finally {
setBatchImportLoading(false)
}
}
const downloadTemplate = (format: 'csv' | 'json') => {
const sampleData = [
{
name: 'Intel Core i5-13400F',
brand: 'Intel',
model: 'i5-13400F',
price: 1299,
description: '10核16线程,基础频率2.5GHz,最大睿频4.6GHz',
imageUrl: '',
stock: 50,
typeName: 'CPU',
specifications: '{"cores":10,"threads":16,"baseClock":"2.5GHz","boostClock":"4.6GHz","socket":"LGA1700"}'
}
]
if (format === 'csv') {
const headers = ['name', 'brand', 'model', 'price', 'description', 'imageUrl', 'stock', 'typeName', 'specifications']
const csvContent = [
headers.join(','),
...sampleData.map(item => headers.map(header => `"${item[header as keyof typeof item] || ''}"`).join(','))
].join('\n')
const blob = new Blob([csvContent], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'components_template.csv'
a.click()
URL.revokeObjectURL(url)
} else {
const jsonContent = JSON.stringify(sampleData, null, 2)
const blob = new Blob([jsonContent], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'components_template.json'
a.click()
URL.revokeObjectURL(url)
}
}
const filteredComponents = components.filter(component =>
component.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.brand.toLowerCase().includes(searchTerm.toLowerCase()) ||
component.model.toLowerCase().includes(searchTerm.toLowerCase())
)
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">配件管理</h1>
<div className="flex gap-3">
<button
onClick={() => setShowBatchModal(true)}
className="bg-green-600 text-white px-4 py-2 rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Upload className="h-5 w-5" />
批量导入
</button>
<button
onClick={() => {
resetForm()
setShowModal(true)
}}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 flex items-center gap-2"
>
<Plus className="h-5 w-5" />
添加配件
</button>
</div>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 h-5 w-5" />
<input
type="text"
placeholder="搜索配件..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-blue-500 focus:border-blue-500"
/>
</div>
</div>
{/* Components Table */}
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
配件信息
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
类型
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
价格
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
库存
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{filteredComponents.map((component) => (
<tr key={component.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<div className="h-10 w-10 flex-shrink-0">
{component.imageUrl ? (
<img loading='lazy'
className="h-10 w-10 rounded-full object-cover"
src={component.imageUrl}
alt={component.name}
/>
) : (
<div className="h-10 w-10 rounded-full bg-gray-300 flex items-center justify-center">
📦
</div>
)}
</div>
<div className="ml-4">
<div className="text-sm font-medium text-gray-900">
{component.name}
</div>
<div className="text-sm text-gray-500">
{component.brand} {component.model}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{component.componentType.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
¥{component.price.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
component.stock > 0
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{component.stock}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(component)}
className="text-blue-600 hover:text-blue-900 mr-4"
>
<Edit className="h-4 w-4" />
</button>
<button
onClick={() => handleDelete(component.id)}
className="text-red-600 hover:text-red-900"
>
<Trash2 className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Modal */}
{showModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">
{editingComponent ? '编辑配件' : '添加配件'}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">配件类型</label>
<select
value={formData.componentTypeId}
onChange={(e) => setFormData({ ...formData, componentTypeId: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
>
<option value="">选择类型</option>
{componentTypes.map((type) => (
<option key={type.id} value={type.id}>
{type.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">配件名称</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">品牌</label>
<input
type="text"
value={formData.brand}
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">型号</label>
<input
type="text"
value={formData.model}
onChange={(e) => setFormData({ ...formData, model: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">价格</label>
<input
type="number"
step="0.01"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">库存</label>
<input
type="number"
value={formData.stock}
onChange={(e) => setFormData({ ...formData, stock: e.target.value })}
required
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">描述</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">图片URL</label>
<input
type="url"
value={formData.imageUrl}
onChange={(e) => setFormData({ ...formData, imageUrl: e.target.value })}
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
<div className="flex justify-end space-x-4 pt-4">
<button
type="button"
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
取消
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingComponent ? '更新' : '创建'}
</button>
</div>
</form>
</div>
</div>
)}
{/* Batch Import Modal */}
{showBatchModal && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">批量导入配件</h2>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">选择文件</label>
<input
type="file"
onChange={handleFileChange}
accept=".json,.csv"
className="mt-1 block w-full border border-gray-300 rounded-md px-3 py-2"
/>
</div>
{batchFile && (
<div className="mb-4">
<p className="text-sm text-gray-500">
已选择文件: <span className="font-medium">{batchFile.name}</span>
</p>
</div>
)}
<div className="mb-4">
<button
onClick={() => downloadTemplate('csv')}
className="mr-2 px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Download className="h-5 w-5" />
下载CSV模板
</button>
<button
onClick={() => downloadTemplate('json')}
className="px-4 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 flex items-center gap-2"
>
<Download className="h-5 w-5" />
下载JSON模板
</button>
</div>
<div className="flex justify-end space-x-4">
<button
type="button"
onClick={() => setShowBatchModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
取消
</button>
<button
onClick={handleBatchImport}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 flex items-center gap-2"
disabled={batchImportLoading}
>
{batchImportLoading ? (
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
) : (
<Upload className="h-5 w-5" />
)}
批量导入
</button>
</div>
{batchData.length > 0 && (
<div className="mt-4">
<h3 className="text-lg font-semibold mb-2">预览数据</h3>
<div className="bg-gray-100 p-4 rounded-md max-h-[300px] overflow-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
{Object.keys(batchData[0]).map((key) => (
<th key={key} className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
{key}
</th>
))}
</tr>
</thead> <tbody className="bg-white divide-y divide-gray-200">
{batchData.map((item, index) => (
<tr key={index}>
{Object.values(item).map((value, i) => (
<td key={i} className="px-4 py-2 whitespace-nowrap text-sm text-gray-900">
{String(value || '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)}
</div>
)
}
export default withAdminAuth(AdminComponentsPage)
+278
View File
@@ -0,0 +1,278 @@
'use client'
import { useState, useEffect } from 'react'
import { withAdminAuth } from '@/components/admin/AdminAuth'
import { Eye, Package, Calendar, User } from 'lucide-react'
interface Order {
id: string
orderNumber: string
totalAmount: number
status: string
createdAt: string
user: {
id: string
email: string
name?: string
username: string
}
orderItems: {
id: string
quantity: number
price: number
component: {
id: string
name: string
brand: string
model: string
}
}[]
}
const statusLabels: { [key: string]: string } = {
PENDING: '待处理',
CONFIRMED: '已确认',
PROCESSING: '处理中',
SHIPPED: '已发货',
DELIVERED: '已送达',
CANCELLED: '已取消'
}
const statusColors: { [key: string]: string } = {
PENDING: 'bg-yellow-100 text-yellow-800',
CONFIRMED: 'bg-blue-100 text-blue-800',
PROCESSING: 'bg-purple-100 text-purple-800',
SHIPPED: 'bg-green-100 text-green-800',
DELIVERED: 'bg-green-100 text-green-800',
CANCELLED: 'bg-red-100 text-red-800'
}
function AdminOrdersPage() {
const [orders, setOrders] = useState<Order[]>([])
const [isLoading, setIsLoading] = useState(true)
const [selectedOrder, setSelectedOrder] = useState<Order | null>(null)
const [showModal, setShowModal] = useState(false)
useEffect(() => {
loadOrders()
}, [])
const loadOrders = async () => {
try {
const response = await fetch('/api/admin/orders')
if (response.ok) {
const data = await response.json()
setOrders(data)
}
} catch (error) {
console.error('加载订单失败:', error)
} finally {
setIsLoading(false)
}
}
const updateOrderStatus = async (orderId: string, newStatus: string) => {
try {
const response = await fetch(`/api/admin/orders/${orderId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status: newStatus }),
})
if (response.ok) {
await loadOrders()
if (selectedOrder && selectedOrder.id === orderId) {
setSelectedOrder({ ...selectedOrder, status: newStatus })
}
} else {
alert('更新订单状态失败')
}
} catch (error) {
console.error('更新订单状态失败:', error)
alert('更新订单状态失败')
}
}
const viewOrderDetails = (order: Order) => {
setSelectedOrder(order)
setShowModal(true)
}
if (isLoading) {
return (
<div className="flex justify-center items-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="flex justify-between items-center mb-6">
<h1 className="text-3xl font-bold text-gray-900">订单管理</h1>
<div className="text-sm text-gray-600">
共 {orders.length} 个订单
</div>
</div>
{/* Orders Table */}
<div className="bg-white shadow-md rounded-lg overflow-hidden">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单号
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
用户
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
金额
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
创建时间
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
操作
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{orders.map((order) => (
<tr key={order.id}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{order.orderNumber}
</div>
<div className="text-sm text-gray-500">
{order.orderItems.length} 个配件
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<User className="h-4 w-4 text-gray-400 mr-2" /> <div>
<div className="text-sm font-medium text-gray-900">
{order.user?.name || order.user?.username || '未知用户'}
</div>
<div className="text-sm text-gray-500">
{order.user?.email || '无邮箱'}
</div>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
¥{order.totalAmount.toLocaleString()}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={order.status}
onChange={(e) => updateOrderStatus(order.id, e.target.value)}
className={`inline-flex px-2 py-1 text-xs rounded-full border-0 ${statusColors[order.status]}`}
>
{Object.entries(statusLabels).map(([status, label]) => (
<option key={status} value={status}>
{label}
</option>
))}
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
<div className="flex items-center">
<Calendar className="h-4 w-4 mr-2" />
{new Date(order.createdAt).toLocaleDateString('zh-CN')}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => viewOrderDetails(order)}
className="text-blue-600 hover:text-blue-900"
>
<Eye className="h-4 w-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Order Details Modal */}
{showModal && selectedOrder && (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<h2 className="text-xl font-bold mb-4">订单详情</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700">订单号</label>
<div className="text-sm text-gray-900">{selectedOrder.orderNumber}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">状态</label>
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${statusColors[selectedOrder.status]}`}>
{statusLabels[selectedOrder.status]}
</span>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">用户</label> <div className="text-sm text-gray-900">
{selectedOrder.user?.name || selectedOrder.user?.username || '未知用户'}
</div>
<div className="text-sm text-gray-500">{selectedOrder.user?.email || '无邮箱'}</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">总金额</label>
<div className="text-sm text-gray-900">¥{selectedOrder.totalAmount.toLocaleString()}</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">订单商品</label>
<div className="border rounded-lg">
{selectedOrder.orderItems.map((item) => (
<div key={item.id} className="p-4 border-b last:border-b-0">
<div className="flex justify-between items-center"> <div>
<div className="font-medium">{item.component?.name || '未知商品'}</div>
<div className="text-sm text-gray-500">
{item.component?.brand || '未知品牌'} {item.component?.model || ''}
</div>
</div>
<div className="text-right">
<div className="font-medium">¥{item.price.toLocaleString()}</div>
<div className="text-sm text-gray-500">数量: {item.quantity}</div>
</div>
</div>
</div>
))}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700">创建时间</label>
<div className="text-sm text-gray-900">
{new Date(selectedOrder.createdAt).toLocaleString('zh-CN')}
</div>
</div>
</div>
<div className="flex justify-end space-x-4 pt-6">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 border border-gray-300 rounded-md hover:bg-gray-50"
>
关闭
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default withAdminAuth(AdminOrdersPage)
+442
View File
@@ -0,0 +1,442 @@
'use client'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import {
Users,
Package,
ShoppingCart,
DollarSign,
TrendingUp,
Eye,
BarChart3,
Settings,
Plus
} from 'lucide-react'
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
interface StatsData {
overview: {
totalUsers: number
totalOrders: number
totalComponents: number
totalRevenue: number
}
topUsers: Array<{
id: string
username: string
name: string
email: string
totalSpending: number
orderCount: number
}>
topComponents: Array<{
id: string
name: string
brand: string
price: number
typeName: string
totalSales: number
totalRevenue: number
}>
recentOrders: Array<{
id: string
orderNumber: string
totalAmount: number
status: string
createdAt: string
user: {
username: string
name?: string
}
orderItems: Array<{
component: {
name: string
}
}>
}>
}
const COLORS = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899']
export default function AdminDashboard() {
const [stats, setStats] = useState<StatsData | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState('overview')
useEffect(() => {
checkAdminAccess()
loadStats()
}, [])
const checkAdminAccess = () => {
const user = JSON.parse(localStorage.getItem('user') || 'null')
if (!user || !user.isAdmin) {
alert('权限不足,需要管理员权限')
window.location.href = '/'
return
}
}
const loadStats = async () => {
try {
const token = localStorage.getItem('token')
const response = await fetch('/api/admin/stats', {
headers: {
'Authorization': `Bearer ${token}`
}
})
if (response.ok) {
const data = await response.json()
setStats(data)
} else if (response.status === 403) {
alert('权限不足')
window.location.href = '/'
}
} catch (error) {
console.error('加载统计数据失败:', error)
} finally {
setIsLoading(false)
}
}
if (isLoading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">加载中...</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold text-gray-900">管理后台</h1>
<p className="text-gray-600">系统管理与数据统计</p>
</div>
<div className="flex space-x-4">
<Link
href="/admin/components"
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus className="h-4 w-4 inline mr-2" />
添加商品
</Link>
<Link
href="/admin/orders"
className="border border-blue-600 text-blue-600 px-4 py-2 rounded-lg hover:bg-blue-50 transition-colors"
>
<Eye className="h-4 w-4 inline mr-2" />
查看订单
</Link>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">总用户数</p>
<p className="text-2xl font-bold text-gray-900">{stats?.overview.totalUsers || 0}</p>
</div>
<Users className="h-12 w-12 text-blue-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">总订单数</p>
<p className="text-2xl font-bold text-gray-900">{stats?.overview.totalOrders || 0}</p>
</div>
<ShoppingCart className="h-12 w-12 text-green-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">商品总数</p>
<p className="text-2xl font-bold text-gray-900">{stats?.overview.totalComponents || 0}</p>
</div>
<Package className="h-12 w-12 text-purple-600" />
</div>
</div>
<div className="bg-white rounded-lg shadow-sm p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-gray-600 text-sm">总营收</p>
<p className="text-2xl font-bold text-gray-900">¥{stats?.overview.totalRevenue?.toFixed(2) || '0.00'}</p>
</div>
<DollarSign className="h-12 w-12 text-red-600" />
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="mb-8">
<div className="border-b border-gray-200">
<nav className="-mb-px flex space-x-8">
<button
onClick={() => setActiveTab('overview')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'overview'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<BarChart3 className="h-4 w-4 inline mr-2" />
数据统计
</button>
<button
onClick={() => setActiveTab('users')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'users'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Users className="h-4 w-4 inline mr-2" />
用户排行
</button>
<button
onClick={() => setActiveTab('products')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'products'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<Package className="h-4 w-4 inline mr-2" />
商品排行
</button>
<button
onClick={() => setActiveTab('orders')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'orders'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
<ShoppingCart className="h-4 w-4 inline mr-2" />
最近订单
</button>
</nav>
</div>
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Top Users Chart */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">用户消费金额排行</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats?.topUsers.slice(0, 5) || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value) => [`¥${value}`, '消费金额']} />
<Bar dataKey="totalSpending" fill="#3B82F6" />
</BarChart>
</ResponsiveContainer>
</div>
{/* Top Components Chart */}
<div className="bg-white rounded-lg shadow-sm p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">商品销量排行</h3>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={stats?.topComponents.slice(0, 5) || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip formatter={(value) => [`${value}件`, '销量']} />
<Bar dataKey="totalSales" fill="#EF4444" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
)}
{activeTab === 'users' && (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">用户消费排行榜</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
排名
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
用户
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
邮箱
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单数
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
消费金额
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats?.topUsers.map((user, index) => (
<tr key={user.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
#{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{user.email}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{user.orderCount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-red-600">
¥{user.totalSpending.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'products' && (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">商品销量排行榜</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
排名
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
商品
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
品牌
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
类型
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
销量
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
营收
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats?.topComponents.map((component, index) => (
<tr key={component.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
#{index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{component.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{component.brand}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{component.typeName}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{component.totalSales}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-red-600">
¥{component.totalRevenue.toFixed(2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{activeTab === 'orders' && (
<div className="bg-white rounded-lg shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900">最近订单</h3>
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单号
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
用户
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
商品数量
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
订单金额
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
状态
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
创建时间
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{stats?.recentOrders.map((order) => (
<tr key={order.id}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{order.orderNumber}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{order.user.name || order.user.username}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{order.orderItems.length} 件
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-red-600">
¥{order.totalAmount}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex px-2 py-1 text-xs font-medium rounded-full bg-blue-100 text-blue-800">
{order.status}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(order.createdAt).toLocaleDateString('zh-CN')}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)
}