2025-06-24 14:09:12 +08:00

736 lines
25 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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: ''
})
// Helper function to get authenticated headers
const getAuthHeaders = (): HeadersInit => {
const token = localStorage.getItem('token')
const headers: HeadersInit = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
return headers
}
useEffect(() => {
loadData()
}, [])
const loadData = async () => {
try {
const headers = getAuthHeaders()
const [componentsRes, typesRes] = await Promise.all([
fetch('/api/admin/components?limit=100', { headers }),
fetch('/api/component-types', { headers })
])
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: getAuthHeaders(),
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',
headers: getAuthHeaders(),
})
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: getAuthHeaders(),
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 max-w-md">
<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 min-w-0 flex-1">
<div className="text-sm font-medium text-gray-900 truncate">
{component.name}
</div>
<div className="text-sm text-gray-500 truncate">
{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 flex items-center justify-center z-50" style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}>
<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 flex items-center justify-center z-50" style={{ backgroundColor: 'rgba(0, 0, 0, 0.5)' }}>
<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-8 flex gap-2">
<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)