728 lines
25 KiB
TypeScript
728 lines
25 KiB
TypeScript
'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)
|