1077 lines
33 KiB
TypeScript
1077 lines
33 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|||
|
|
import {
|
||
|
|
Table, Button, Modal, Form, Input, Select, message, Space, Tag, Card,
|
||
|
|
Row, Col, Statistic, TreeSelect, Image, Popconfirm, Tabs, Empty, Spin, InputNumber,
|
||
|
|
Upload, Progress
|
||
|
|
} from 'antd'
|
||
|
|
import {
|
||
|
|
PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined,
|
||
|
|
ShopOutlined, AppstoreOutlined, FolderOutlined, FolderAddOutlined,
|
||
|
|
PictureOutlined, UploadOutlined, DownloadOutlined
|
||
|
|
} from '@ant-design/icons'
|
||
|
|
import type { ColumnsType } from 'antd/es/table'
|
||
|
|
import type { DataNode } from 'antd/es/tree'
|
||
|
|
|
||
|
|
// ==================== 类型定义 ====================
|
||
|
|
interface Category {
|
||
|
|
id: number
|
||
|
|
name: string
|
||
|
|
parent_id: number | null
|
||
|
|
parent_name?: string
|
||
|
|
children?: Category[]
|
||
|
|
created_at: string
|
||
|
|
}
|
||
|
|
|
||
|
|
interface Product {
|
||
|
|
id: number
|
||
|
|
name: string
|
||
|
|
model: string | null
|
||
|
|
category_id: number
|
||
|
|
category_name: string
|
||
|
|
parent_category_name: string | null
|
||
|
|
unit: string
|
||
|
|
quantity: number
|
||
|
|
cost_price: number
|
||
|
|
thumbnail: string | null
|
||
|
|
specification: string | null
|
||
|
|
brand: string | null
|
||
|
|
remark: string | null
|
||
|
|
source: string | null
|
||
|
|
created_at: string
|
||
|
|
updated_at: string
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==================== 组件 ====================
|
||
|
|
const ProductPage: React.FC = () => {
|
||
|
|
// 商品列表状态
|
||
|
|
const [products, setProducts] = useState<Product[]>([])
|
||
|
|
const [loading, setLoading] = useState(false)
|
||
|
|
const [total, setTotal] = useState(0)
|
||
|
|
const [page, setPage] = useState(1)
|
||
|
|
const [pageSize, setPageSize] = useState(20)
|
||
|
|
const [searchText, setSearchText] = useState('')
|
||
|
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
|
||
|
|
|
||
|
|
// 分类状态
|
||
|
|
const [categories, setCategories] = useState<Category[]>([])
|
||
|
|
const [categoryTree, setCategoryTree] = useState<DataNode[]>([])
|
||
|
|
const [categoryLoading, setCategoryLoading] = useState(false)
|
||
|
|
|
||
|
|
// 弹窗状态
|
||
|
|
const [productModalVisible, setProductModalVisible] = useState(false)
|
||
|
|
const [categoryModalVisible, setCategoryModalVisible] = useState(false)
|
||
|
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null)
|
||
|
|
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
|
||
|
|
|
||
|
|
// 表单
|
||
|
|
const [productForm] = Form.useForm()
|
||
|
|
const [categoryForm] = Form.useForm()
|
||
|
|
|
||
|
|
// Tab状态
|
||
|
|
const [activeTab, setActiveTab] = useState('products')
|
||
|
|
|
||
|
|
// 批量上传状态
|
||
|
|
const [importModalVisible, setImportModalVisible] = useState(false)
|
||
|
|
const [importLoading, setImportLoading] = useState(false)
|
||
|
|
const [importProgress, setImportProgress] = useState(0)
|
||
|
|
|
||
|
|
// ==================== 数据加载 ====================
|
||
|
|
|
||
|
|
// 加载分类(从分类API获取)
|
||
|
|
const fetchCategories = async () => {
|
||
|
|
setCategoryLoading(true)
|
||
|
|
try {
|
||
|
|
// 从分类API获取所有分类
|
||
|
|
const response = await fetch('/api/categories')
|
||
|
|
const data = await response.json()
|
||
|
|
if (data.success && data.data && Array.isArray(data.data)) {
|
||
|
|
const cats = data.data.map((cat: any) => ({
|
||
|
|
id: cat.id,
|
||
|
|
name: cat.name,
|
||
|
|
parent_id: cat.parent_id,
|
||
|
|
level: cat.level,
|
||
|
|
created_at: cat.created_at
|
||
|
|
}))
|
||
|
|
setCategories(cats)
|
||
|
|
setCategoryTree(convertToTreeData(cats))
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('获取分类失败:', error)
|
||
|
|
} finally {
|
||
|
|
setCategoryLoading(false)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 加载商品
|
||
|
|
const fetchProducts = async () => {
|
||
|
|
setLoading(true)
|
||
|
|
try {
|
||
|
|
console.log('开始获取商品数据')
|
||
|
|
const params = new URLSearchParams({
|
||
|
|
page: page.toString(),
|
||
|
|
pageSize: pageSize.toString(),
|
||
|
|
...(selectedCategoryId && { category_id: selectedCategoryId.toString() }),
|
||
|
|
...(searchText && { search: searchText })
|
||
|
|
})
|
||
|
|
|
||
|
|
console.log('请求URL:', `/api/products?${params}`)
|
||
|
|
const response = await fetch(`/api/products?${params}`)
|
||
|
|
console.log('响应状态:', response.status)
|
||
|
|
const data = await response.json()
|
||
|
|
console.log('响应数据:', data)
|
||
|
|
|
||
|
|
if (data.success && data.data && Array.isArray(data.data)) {
|
||
|
|
console.log('商品数据:', data.data.length, '条')
|
||
|
|
setProducts(data.data)
|
||
|
|
setTotal(data.total || data.data.length)
|
||
|
|
} else {
|
||
|
|
console.error('获取商品失败:', data)
|
||
|
|
setProducts([])
|
||
|
|
setTotal(0)
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('获取商品失败:', error)
|
||
|
|
message.error('获取商品列表失败')
|
||
|
|
setProducts([])
|
||
|
|
setTotal(0)
|
||
|
|
} finally {
|
||
|
|
setLoading(false)
|
||
|
|
console.log('获取商品完成')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchCategories()
|
||
|
|
}, [])
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchProducts()
|
||
|
|
}, [page, pageSize, selectedCategoryId])
|
||
|
|
|
||
|
|
// ==================== 工具函数 ====================
|
||
|
|
|
||
|
|
// 转换分类为Tree组件数据
|
||
|
|
const convertToTreeData = (cats: Category[]): DataNode[] => {
|
||
|
|
const map: Record<number, DataNode> = {}
|
||
|
|
const roots: DataNode[] = []
|
||
|
|
|
||
|
|
cats.forEach(c => {
|
||
|
|
map[c.id] = {
|
||
|
|
key: c.id,
|
||
|
|
title: c.name,
|
||
|
|
children: []
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
cats.forEach(c => {
|
||
|
|
if (c.parent_id === null) {
|
||
|
|
roots.push(map[c.id])
|
||
|
|
} else if (map[c.parent_id]) {
|
||
|
|
map[c.parent_id].children!.push(map[c.id])
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
return roots
|
||
|
|
}
|
||
|
|
|
||
|
|
// 转换分类为TreeSelect组件数据
|
||
|
|
const convertToTreeSelectData = (cats: Category[]): any[] => {
|
||
|
|
const map: Record<number, any> = {}
|
||
|
|
const roots: any[] = []
|
||
|
|
|
||
|
|
cats.forEach(c => {
|
||
|
|
map[c.id] = {
|
||
|
|
value: c.id,
|
||
|
|
title: c.name,
|
||
|
|
children: []
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
cats.forEach(c => {
|
||
|
|
if (c.parent_id === null) {
|
||
|
|
roots.push(map[c.id])
|
||
|
|
} else if (map[c.parent_id]) {
|
||
|
|
map[c.parent_id].children.push(map[c.id])
|
||
|
|
}
|
||
|
|
})
|
||
|
|
|
||
|
|
return roots
|
||
|
|
}
|
||
|
|
|
||
|
|
// 获取一级分类选项
|
||
|
|
const getParentCategoryOptions = () => {
|
||
|
|
return categories
|
||
|
|
.filter(c => c.parent_id === null)
|
||
|
|
.map(c => ({ label: c.name, value: c.id }))
|
||
|
|
}
|
||
|
|
|
||
|
|
// 获取二级分类选项(根据选择的一级分类)
|
||
|
|
const getChildCategoryOptions = (parentId: number | null) => {
|
||
|
|
if (!parentId) return []
|
||
|
|
return categories
|
||
|
|
.filter(c => c.parent_id === parentId)
|
||
|
|
.map(c => ({ label: c.name, value: c.id }))
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==================== 商品操作 ====================
|
||
|
|
|
||
|
|
// 打开新增商品弹窗
|
||
|
|
const handleAddProduct = () => {
|
||
|
|
setEditingProduct(null)
|
||
|
|
productForm.resetFields()
|
||
|
|
productForm.setFieldsValue({
|
||
|
|
unit: '个',
|
||
|
|
cost_price: 0,
|
||
|
|
source: '老挝'
|
||
|
|
})
|
||
|
|
setProductModalVisible(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 打开编辑商品弹窗
|
||
|
|
const handleEditProduct = (product: Product) => {
|
||
|
|
setEditingProduct(product)
|
||
|
|
// 找到父分类ID
|
||
|
|
const parentCat = categories.find(c => c.name === product.parent_category_name && c.parent_id === null)
|
||
|
|
productForm.setFieldsValue({
|
||
|
|
name: product.name,
|
||
|
|
model: product.model,
|
||
|
|
parent_category_id: parentCat?.id || null,
|
||
|
|
category_id: product.category_id,
|
||
|
|
unit: product.unit,
|
||
|
|
cost_price: product.cost_price,
|
||
|
|
specification: product.specification,
|
||
|
|
brand: product.brand,
|
||
|
|
remark: product.remark,
|
||
|
|
source: product.source || '老挝'
|
||
|
|
})
|
||
|
|
setProductModalVisible(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 保存商品
|
||
|
|
const handleSaveProduct = async () => {
|
||
|
|
try {
|
||
|
|
const values = await productForm.validateFields()
|
||
|
|
|
||
|
|
const url = editingProduct
|
||
|
|
? `/api/products/${editingProduct.id}`
|
||
|
|
: '/api/products'
|
||
|
|
const method = editingProduct ? 'PUT' : 'POST'
|
||
|
|
|
||
|
|
const response = await fetch(url, {
|
||
|
|
method,
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
name: values.name,
|
||
|
|
model: values.model,
|
||
|
|
category_id: values.category_id || values.parent_category_id,
|
||
|
|
unit: values.unit,
|
||
|
|
cost_price: values.cost_price || 0,
|
||
|
|
specification: values.specification,
|
||
|
|
brand: values.brand,
|
||
|
|
remark: values.remark,
|
||
|
|
source: values.source || '老挝'
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
const data = await response.json()
|
||
|
|
if (data.success) {
|
||
|
|
message.success(editingProduct ? '更新成功' : '创建成功')
|
||
|
|
setProductModalVisible(false)
|
||
|
|
fetchProducts()
|
||
|
|
fetchCategories() // 刷新分类
|
||
|
|
} else {
|
||
|
|
message.error(data.error || '操作失败')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('保存商品失败:', error)
|
||
|
|
message.error('保存失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 删除商品
|
||
|
|
const handleDeleteProduct = async (id: number) => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/products/${id}`, { method: 'DELETE' })
|
||
|
|
const data = await response.json()
|
||
|
|
if (data.success) {
|
||
|
|
message.success('删除成功')
|
||
|
|
fetchProducts()
|
||
|
|
} else {
|
||
|
|
message.error(data.error || '删除失败')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('删除商品失败:', error)
|
||
|
|
message.error('删除失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 批量上传商品
|
||
|
|
const handleBatchImport = async (file: any) => {
|
||
|
|
setImportLoading(true)
|
||
|
|
setImportProgress(0)
|
||
|
|
|
||
|
|
try {
|
||
|
|
console.log('文件信息:', file)
|
||
|
|
const formData = new FormData()
|
||
|
|
formData.append('file', file)
|
||
|
|
|
||
|
|
console.log('表单数据:', formData)
|
||
|
|
console.log('发送请求到:', '/api/products/batch-import')
|
||
|
|
const response = await fetch('/api/products/batch-import', {
|
||
|
|
method: 'POST',
|
||
|
|
body: formData
|
||
|
|
})
|
||
|
|
|
||
|
|
console.log('响应状态:', response.status)
|
||
|
|
console.log('响应状态文本:', response.statusText)
|
||
|
|
const data = await response.json()
|
||
|
|
console.log('响应数据:', data)
|
||
|
|
|
||
|
|
if (data.success) {
|
||
|
|
message.success(data.message)
|
||
|
|
if (data.data && data.data.errorCount > 0 && data.data.errors) {
|
||
|
|
// 显示失败的详细信息(限制显示数量)
|
||
|
|
const errorDetails = data.data.errors.slice(0, 5).map((err: any) => `${err.item || '未知商品'}: ${err.error}`).join('\n')
|
||
|
|
const moreErrors = data.data.errorCount > 5 ? `\n...等${data.data.errorCount - 5}条错误` : ''
|
||
|
|
message.error(`导入失败 ${data.data.errorCount} 条:\n${errorDetails}${moreErrors}`)
|
||
|
|
}
|
||
|
|
fetchProducts()
|
||
|
|
fetchCategories()
|
||
|
|
} else {
|
||
|
|
message.error(data.error || '导入失败')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('批量导入失败:', error)
|
||
|
|
message.error('导入失败')
|
||
|
|
} finally {
|
||
|
|
setImportLoading(false)
|
||
|
|
setImportProgress(0)
|
||
|
|
setImportModalVisible(false)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 阻止自动上传
|
||
|
|
return false
|
||
|
|
}
|
||
|
|
|
||
|
|
// 下载商品模板
|
||
|
|
const handleDownloadTemplate = () => {
|
||
|
|
window.open('/api/products/template', '_blank')
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==================== 分类操作 ====================
|
||
|
|
|
||
|
|
// 打开新增分类弹窗
|
||
|
|
const handleAddCategory = () => {
|
||
|
|
setEditingCategory(null)
|
||
|
|
categoryForm.resetFields()
|
||
|
|
categoryForm.setFieldsValue({ parent_id: null, level: 1 })
|
||
|
|
setCategoryModalVisible(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 打开编辑分类弹窗
|
||
|
|
const handleEditCategory = (category: Category) => {
|
||
|
|
setEditingCategory(category)
|
||
|
|
categoryForm.setFieldsValue({
|
||
|
|
name: category.name,
|
||
|
|
parent_id: category.parent_id,
|
||
|
|
level: category.level
|
||
|
|
})
|
||
|
|
setCategoryModalVisible(true)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 保存分类
|
||
|
|
const handleSaveCategory = async () => {
|
||
|
|
try {
|
||
|
|
const values = await categoryForm.validateFields()
|
||
|
|
|
||
|
|
const url = editingCategory
|
||
|
|
? `/api/categories/${editingCategory.id}`
|
||
|
|
: '/api/categories'
|
||
|
|
const method = editingCategory ? 'PUT' : 'POST'
|
||
|
|
|
||
|
|
const response = await fetch(url, {
|
||
|
|
method,
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify(values)
|
||
|
|
})
|
||
|
|
|
||
|
|
const data = await response.json()
|
||
|
|
if (data.success) {
|
||
|
|
message.success(editingCategory ? '分类更新成功' : '分类创建成功')
|
||
|
|
setCategoryModalVisible(false)
|
||
|
|
fetchCategories()
|
||
|
|
} else {
|
||
|
|
message.error(data.message || '操作失败')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('保存分类失败:', error)
|
||
|
|
message.error('保存失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 删除分类
|
||
|
|
const handleDeleteCategory = async (id: number) => {
|
||
|
|
try {
|
||
|
|
const response = await fetch(`/api/categories/${id}`, { method: 'DELETE' })
|
||
|
|
const data = await response.json()
|
||
|
|
if (data.success) {
|
||
|
|
message.success('分类删除成功')
|
||
|
|
fetchCategories()
|
||
|
|
} else {
|
||
|
|
message.error(data.message || '删除失败')
|
||
|
|
}
|
||
|
|
} catch (error) {
|
||
|
|
console.error('删除分类失败:', error)
|
||
|
|
message.error('删除失败')
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==================== 表格列定义 ====================
|
||
|
|
const columns: ColumnsType<Product> = [
|
||
|
|
{
|
||
|
|
title: '缩略图',
|
||
|
|
dataIndex: 'thumbnail',
|
||
|
|
key: 'thumbnail',
|
||
|
|
width: 80,
|
||
|
|
render: (thumbnail) => (
|
||
|
|
thumbnail ? (
|
||
|
|
<Image
|
||
|
|
src={thumbnail}
|
||
|
|
width={50}
|
||
|
|
height={50}
|
||
|
|
style={{ objectFit: 'cover', borderRadius: 4 }}
|
||
|
|
fallback="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div style={{
|
||
|
|
width: 50, height: 50,
|
||
|
|
background: '#f5f5f5',
|
||
|
|
borderRadius: 4,
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
justifyContent: 'center'
|
||
|
|
}}>
|
||
|
|
<PictureOutlined style={{ color: '#ccc' }} />
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '商品名称',
|
||
|
|
dataIndex: 'name',
|
||
|
|
key: 'name',
|
||
|
|
width: 200,
|
||
|
|
render: (text) => <span style={{ fontWeight: 500 }}>{text}</span>
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '型号',
|
||
|
|
dataIndex: 'model',
|
||
|
|
key: 'model',
|
||
|
|
width: 150,
|
||
|
|
render: (text) => text || '-'
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '一级分类',
|
||
|
|
dataIndex: 'parent_category_name',
|
||
|
|
key: 'parent_category_name',
|
||
|
|
width: 100,
|
||
|
|
render: (text, record) => (
|
||
|
|
<Tag color="blue">{text || record.category_name}</Tag>
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '二级分类',
|
||
|
|
dataIndex: 'category_name',
|
||
|
|
key: 'category_name',
|
||
|
|
width: 100,
|
||
|
|
render: (text, record) => (
|
||
|
|
record.parent_category_name ? <Tag color="green">{text}</Tag> : '-'
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '单位',
|
||
|
|
dataIndex: 'unit',
|
||
|
|
key: 'unit',
|
||
|
|
width: 60
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '数量',
|
||
|
|
dataIndex: 'quantity',
|
||
|
|
key: 'quantity',
|
||
|
|
width: 80,
|
||
|
|
render: (qty) => (
|
||
|
|
<span style={{ color: (qty || 0) > 0 ? '#52c41a' : '#999' }}>
|
||
|
|
{(qty || 0).toLocaleString()}
|
||
|
|
</span>
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '成本单价',
|
||
|
|
dataIndex: 'cost_price',
|
||
|
|
key: 'cost_price',
|
||
|
|
width: 100,
|
||
|
|
render: (price) => (
|
||
|
|
<span style={{ color: (price || 0) > 0 ? '#1890ff' : '#999' }}>
|
||
|
|
{(price || 0) > 0 ? `¥${Number(price || 0).toFixed(2)}` : '¥0.00'}
|
||
|
|
</span>
|
||
|
|
)
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '品牌',
|
||
|
|
dataIndex: 'brand',
|
||
|
|
key: 'brand',
|
||
|
|
width: 100,
|
||
|
|
render: (text) => text || '-'
|
||
|
|
},
|
||
|
|
{
|
||
|
|
title: '操作',
|
||
|
|
key: 'action',
|
||
|
|
width: 120,
|
||
|
|
fixed: 'right',
|
||
|
|
render: (_, record) => (
|
||
|
|
<Space size="small">
|
||
|
|
<Button
|
||
|
|
type="link"
|
||
|
|
size="small"
|
||
|
|
icon={<EditOutlined />}
|
||
|
|
onClick={() => handleEditProduct(record)}
|
||
|
|
>
|
||
|
|
编辑
|
||
|
|
</Button>
|
||
|
|
<Popconfirm
|
||
|
|
title="确定删除此商品吗?"
|
||
|
|
onConfirm={() => handleDeleteProduct(record.id)}
|
||
|
|
okText="确定"
|
||
|
|
cancelText="取消"
|
||
|
|
>
|
||
|
|
<Button
|
||
|
|
type="link"
|
||
|
|
size="small"
|
||
|
|
danger
|
||
|
|
icon={<DeleteOutlined />}
|
||
|
|
>
|
||
|
|
删除
|
||
|
|
</Button>
|
||
|
|
</Popconfirm>
|
||
|
|
</Space>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
]
|
||
|
|
|
||
|
|
// ==================== 渲染 ====================
|
||
|
|
|
||
|
|
// 统计数据
|
||
|
|
const stats = {
|
||
|
|
totalProducts: total,
|
||
|
|
totalCategories: categories.length,
|
||
|
|
parentCategories: categories.filter(c => c.parent_id === null).length,
|
||
|
|
childCategories: categories.filter(c => c.parent_id !== null).length
|
||
|
|
}
|
||
|
|
|
||
|
|
// 分类展开/折叠状态
|
||
|
|
const [expandedCategories, setExpandedCategories] = useState<Set<number>>(new Set())
|
||
|
|
|
||
|
|
// 切换分类展开/折叠
|
||
|
|
const toggleCategory = (id: number) => {
|
||
|
|
const newExpanded = new Set(expandedCategories)
|
||
|
|
if (newExpanded.has(id)) {
|
||
|
|
newExpanded.delete(id)
|
||
|
|
} else {
|
||
|
|
newExpanded.add(id)
|
||
|
|
}
|
||
|
|
setExpandedCategories(newExpanded)
|
||
|
|
}
|
||
|
|
|
||
|
|
// 分类树渲染(支持编辑和展开/折叠)
|
||
|
|
const renderCategoryTree = () => {
|
||
|
|
const renderTreeNodes = (cats: Category[]): React.ReactNode => {
|
||
|
|
return cats.map(cat => {
|
||
|
|
const childCategories = categories.filter(c => c.parent_id === cat.id)
|
||
|
|
const isParent = cat.parent_id === null
|
||
|
|
const isExpanded = expandedCategories.has(cat.id)
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div key={cat.id} style={{ marginBottom: 8 }}>
|
||
|
|
<div style={{
|
||
|
|
display: 'flex',
|
||
|
|
alignItems: 'center',
|
||
|
|
padding: '8px 12px',
|
||
|
|
background: isParent ? '#f0f5ff' : '#f6ffed',
|
||
|
|
borderRadius: 6,
|
||
|
|
border: '1px solid #e8e8e8',
|
||
|
|
cursor: isParent && childCategories.length > 0 ? 'pointer' : 'default'
|
||
|
|
}} onClick={() => isParent && childCategories.length > 0 && toggleCategory(cat.id)}>
|
||
|
|
{isParent && childCategories.length > 0 && (
|
||
|
|
<span style={{ marginRight: 8, fontSize: '12px', color: '#666' }}>
|
||
|
|
{isExpanded ? '▼' : '▶'}
|
||
|
|
</span>
|
||
|
|
)}
|
||
|
|
{(!isParent || childCategories.length === 0) && (
|
||
|
|
<span style={{ marginRight: 8, width: '12px' }}></span>
|
||
|
|
)}
|
||
|
|
<FolderOutlined style={{ marginRight: 8, color: isParent ? '#1890ff' : '#52c41a' }} />
|
||
|
|
<span style={{ flex: 1, fontWeight: isParent ? 500 : 400 }}>
|
||
|
|
{cat.name}
|
||
|
|
</span>
|
||
|
|
<Space size="small">
|
||
|
|
<Button
|
||
|
|
type="link"
|
||
|
|
size="small"
|
||
|
|
icon={<EditOutlined />}
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation()
|
||
|
|
handleEditCategory(cat)
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
编辑
|
||
|
|
</Button>
|
||
|
|
<Popconfirm
|
||
|
|
title="确定删除此分类吗?"
|
||
|
|
onConfirm={() => handleDeleteCategory(cat.id)}
|
||
|
|
okText="确定"
|
||
|
|
cancelText="取消"
|
||
|
|
>
|
||
|
|
<Button
|
||
|
|
type="link"
|
||
|
|
size="small"
|
||
|
|
danger
|
||
|
|
icon={<DeleteOutlined />}
|
||
|
|
onClick={(e) => e.stopPropagation()}
|
||
|
|
>
|
||
|
|
删除
|
||
|
|
</Button>
|
||
|
|
</Popconfirm>
|
||
|
|
<Tag color={isParent ? 'blue' : 'green'}>
|
||
|
|
{isParent ? '一级分类' : '二级分类'}
|
||
|
|
</Tag>
|
||
|
|
</Space>
|
||
|
|
</div>
|
||
|
|
{isParent && childCategories.length > 0 && isExpanded && (
|
||
|
|
<div key={`${cat.id}-children`} style={{ marginLeft: 24, marginTop: 8 }}>
|
||
|
|
{renderTreeNodes(childCategories)}
|
||
|
|
{/* 二级分类新增按钮 */}
|
||
|
|
<div style={{ marginTop: 8, textAlign: 'center' }}>
|
||
|
|
<Button
|
||
|
|
type="dashed"
|
||
|
|
size="small"
|
||
|
|
icon={<PlusOutlined />}
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation()
|
||
|
|
setEditingCategory(null)
|
||
|
|
categoryForm.resetFields()
|
||
|
|
categoryForm.setFieldsValue({
|
||
|
|
parent_id: cat.id,
|
||
|
|
level: 2
|
||
|
|
})
|
||
|
|
setCategoryModalVisible(true)
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
新增二级分类
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
const parentCategories = categories.filter(c => c.parent_id === null)
|
||
|
|
const treeNodes = renderTreeNodes(parentCategories)
|
||
|
|
|
||
|
|
// 在一级分类列表末尾添加新增按钮
|
||
|
|
return (
|
||
|
|
<div>
|
||
|
|
{treeNodes}
|
||
|
|
<div style={{ marginTop: 16, textAlign: 'center' }}>
|
||
|
|
<Button
|
||
|
|
type="dashed"
|
||
|
|
icon={<PlusOutlined />}
|
||
|
|
onClick={handleAddCategory}
|
||
|
|
>
|
||
|
|
新增一级分类
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div style={{ padding: 24 }}>
|
||
|
|
{/* 统计卡片 */}
|
||
|
|
<Row gutter={16} style={{ marginBottom: 24 }}>
|
||
|
|
<Col span={6}>
|
||
|
|
<Card>
|
||
|
|
<Statistic
|
||
|
|
title="商品总数"
|
||
|
|
value={stats.totalProducts}
|
||
|
|
prefix={<ShopOutlined />}
|
||
|
|
/>
|
||
|
|
</Card>
|
||
|
|
</Col>
|
||
|
|
<Col span={6}>
|
||
|
|
<Card>
|
||
|
|
<Statistic
|
||
|
|
title="分类总数"
|
||
|
|
value={stats.totalCategories}
|
||
|
|
prefix={<AppstoreOutlined />}
|
||
|
|
/>
|
||
|
|
</Card>
|
||
|
|
</Col>
|
||
|
|
<Col span={6}>
|
||
|
|
<Card>
|
||
|
|
<Statistic
|
||
|
|
title="一级分类"
|
||
|
|
value={stats.parentCategories}
|
||
|
|
valueStyle={{ color: '#1890ff' }}
|
||
|
|
/>
|
||
|
|
</Card>
|
||
|
|
</Col>
|
||
|
|
<Col span={6}>
|
||
|
|
<Card>
|
||
|
|
<Statistic
|
||
|
|
title="二级分类"
|
||
|
|
value={stats.childCategories}
|
||
|
|
valueStyle={{ color: '#52c41a' }}
|
||
|
|
/>
|
||
|
|
</Card>
|
||
|
|
</Col>
|
||
|
|
</Row>
|
||
|
|
|
||
|
|
{/* 主内容区 */}
|
||
|
|
<Card>
|
||
|
|
<Tabs
|
||
|
|
activeKey={activeTab}
|
||
|
|
onChange={setActiveTab}
|
||
|
|
tabBarExtraContent={
|
||
|
|
activeTab === 'products' ? (
|
||
|
|
<Space>
|
||
|
|
<Input.Search
|
||
|
|
placeholder="搜索商品名称/型号/品牌"
|
||
|
|
allowClear
|
||
|
|
style={{ width: 250 }}
|
||
|
|
value={searchText}
|
||
|
|
onChange={e => setSearchText(e.target.value)}
|
||
|
|
onSearch={() => {
|
||
|
|
setPage(1)
|
||
|
|
fetchProducts()
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
icon={<DownloadOutlined />}
|
||
|
|
onClick={handleDownloadTemplate}
|
||
|
|
>
|
||
|
|
下载模板
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
icon={<UploadOutlined />}
|
||
|
|
onClick={() => setImportModalVisible(true)}
|
||
|
|
>
|
||
|
|
批量上传
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
type="primary"
|
||
|
|
icon={<PlusOutlined />}
|
||
|
|
onClick={handleAddProduct}
|
||
|
|
>
|
||
|
|
新增商品
|
||
|
|
</Button>
|
||
|
|
</Space>
|
||
|
|
) : null
|
||
|
|
}
|
||
|
|
>
|
||
|
|
<Tabs.TabPane
|
||
|
|
tab={<span><ShopOutlined /> 商品列表</span>}
|
||
|
|
key="products"
|
||
|
|
>
|
||
|
|
{/* 分类筛选 */}
|
||
|
|
<div style={{ marginBottom: 16 }}>
|
||
|
|
<Space>
|
||
|
|
<span>按分类筛选:</span>
|
||
|
|
<TreeSelect
|
||
|
|
style={{ width: 250 }}
|
||
|
|
placeholder="选择分类"
|
||
|
|
allowClear
|
||
|
|
treeData={convertToTreeSelectData(categories)}
|
||
|
|
onChange={(value) => {
|
||
|
|
setSelectedCategoryId(value)
|
||
|
|
setPage(1)
|
||
|
|
}}
|
||
|
|
value={selectedCategoryId}
|
||
|
|
/>
|
||
|
|
{selectedCategoryId && (
|
||
|
|
<Button onClick={() => setSelectedCategoryId(null)}>
|
||
|
|
清除筛选
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</Space>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Table
|
||
|
|
columns={columns}
|
||
|
|
dataSource={products || []}
|
||
|
|
rowKey="id"
|
||
|
|
loading={loading}
|
||
|
|
scroll={{ x: 1200 }}
|
||
|
|
pagination={{
|
||
|
|
current: page,
|
||
|
|
pageSize,
|
||
|
|
total: total || 0,
|
||
|
|
showSizeChanger: true,
|
||
|
|
showQuickJumper: true,
|
||
|
|
showTotal: (total) => `共 ${total} 条`,
|
||
|
|
onChange: (p, ps) => {
|
||
|
|
setPage(p)
|
||
|
|
setPageSize(ps)
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
</Tabs.TabPane>
|
||
|
|
|
||
|
|
<Tabs.TabPane
|
||
|
|
tab={<span><FolderOutlined /> 分类管理</span>}
|
||
|
|
key="categories"
|
||
|
|
>
|
||
|
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||
|
|
<Button
|
||
|
|
type="primary"
|
||
|
|
icon={<FolderAddOutlined />}
|
||
|
|
onClick={handleAddCategory}
|
||
|
|
>
|
||
|
|
新增分类
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
{categoryLoading ? (
|
||
|
|
<div style={{ textAlign: 'center', padding: 40 }}>
|
||
|
|
<Spin />
|
||
|
|
</div>
|
||
|
|
) : categories.length === 0 ? (
|
||
|
|
<Empty description="暂无分类" />
|
||
|
|
) : (
|
||
|
|
<div style={{ padding: 16 }}>
|
||
|
|
{renderCategoryTree()}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Tabs.TabPane>
|
||
|
|
</Tabs>
|
||
|
|
</Card>
|
||
|
|
|
||
|
|
{/* 商品弹窗 */}
|
||
|
|
<Modal
|
||
|
|
title={editingProduct ? '编辑商品' : '新增商品'}
|
||
|
|
open={productModalVisible}
|
||
|
|
onOk={handleSaveProduct}
|
||
|
|
onCancel={() => setProductModalVisible(false)}
|
||
|
|
width={600}
|
||
|
|
okText="保存"
|
||
|
|
cancelText="取消"
|
||
|
|
>
|
||
|
|
<Form form={productForm} layout="vertical">
|
||
|
|
<Row gutter={16}>
|
||
|
|
<Col span={12}>
|
||
|
|
<Form.Item
|
||
|
|
name="name"
|
||
|
|
label="商品名称"
|
||
|
|
rules={[{ required: true, message: '请输入商品名称' }]}
|
||
|
|
>
|
||
|
|
<Input placeholder="如:JKLYJ-120-22kV高压绝缘线" />
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
<Col span={12}>
|
||
|
|
<Form.Item name="model" label="型号">
|
||
|
|
<Input placeholder="如:JKLYJ-120-22kV" />
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
</Row>
|
||
|
|
|
||
|
|
<Row gutter={16}>
|
||
|
|
<Col span={12}>
|
||
|
|
<Form.Item
|
||
|
|
name="parent_category_id"
|
||
|
|
label="一级分类"
|
||
|
|
rules={[{ required: true, message: '请选择一级分类' }]}
|
||
|
|
>
|
||
|
|
<Select
|
||
|
|
placeholder="选择一级分类"
|
||
|
|
onChange={(value) => {
|
||
|
|
productForm.setFieldsValue({ category_id: null })
|
||
|
|
}}
|
||
|
|
options={getParentCategoryOptions()}
|
||
|
|
/>
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
<Col span={12}>
|
||
|
|
<Form.Item
|
||
|
|
name="category_id"
|
||
|
|
label="二级分类"
|
||
|
|
extra="可选,不选则使用一级分类"
|
||
|
|
>
|
||
|
|
<Select
|
||
|
|
placeholder="选择二级分类(可选)"
|
||
|
|
allowClear
|
||
|
|
options={getChildCategoryOptions(
|
||
|
|
productForm.getFieldValue('parent_category_id')
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
</Row>
|
||
|
|
|
||
|
|
<Row gutter={16}>
|
||
|
|
<Col span={8}>
|
||
|
|
<Form.Item name="unit" label="单位">
|
||
|
|
<Select placeholder="选择单位">
|
||
|
|
<Select.Option value="个">个</Select.Option>
|
||
|
|
<Select.Option value="米">米</Select.Option>
|
||
|
|
<Select.Option value="根">根</Select.Option>
|
||
|
|
<Select.Option value="套">套</Select.Option>
|
||
|
|
<Select.Option value="台">台</Select.Option>
|
||
|
|
<Select.Option value="件">件</Select.Option>
|
||
|
|
<Select.Option value="箱">箱</Select.Option>
|
||
|
|
<Select.Option value="kg">kg</Select.Option>
|
||
|
|
<Select.Option value="吨">吨</Select.Option>
|
||
|
|
</Select>
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
<Col span={8}>
|
||
|
|
<Form.Item name="cost_price" label="成本单价">
|
||
|
|
<InputNumber
|
||
|
|
min={0}
|
||
|
|
precision={2}
|
||
|
|
style={{ width: '100%' }}
|
||
|
|
placeholder="默认为0"
|
||
|
|
addonBefore="¥"
|
||
|
|
/>
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
<Col span={8}>
|
||
|
|
<Form.Item name="source" label="来源">
|
||
|
|
<Select defaultValue="老挝" placeholder="选择来源">
|
||
|
|
<Select.Option value="中国">中国</Select.Option>
|
||
|
|
<Select.Option value="老挝">老挝</Select.Option>
|
||
|
|
</Select>
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
</Row>
|
||
|
|
|
||
|
|
<Row gutter={16}>
|
||
|
|
<Col span={24}>
|
||
|
|
<Form.Item name="brand" label="品牌">
|
||
|
|
<Input placeholder="品牌名称" />
|
||
|
|
</Form.Item>
|
||
|
|
</Col>
|
||
|
|
</Row>
|
||
|
|
|
||
|
|
<Form.Item name="specification" label="规格参数">
|
||
|
|
<Input.TextArea rows={2} placeholder="如:120mm², 22kV" />
|
||
|
|
</Form.Item>
|
||
|
|
|
||
|
|
<Form.Item name="remark" label="备注">
|
||
|
|
<Input.TextArea rows={2} placeholder="其他说明" />
|
||
|
|
</Form.Item>
|
||
|
|
</Form>
|
||
|
|
</Modal>
|
||
|
|
|
||
|
|
{/* 批量上传弹窗 */}
|
||
|
|
<Modal
|
||
|
|
title="批量上传商品"
|
||
|
|
open={importModalVisible}
|
||
|
|
onCancel={() => setImportModalVisible(false)}
|
||
|
|
footer={null}
|
||
|
|
width={500}
|
||
|
|
maskClosable={false}
|
||
|
|
>
|
||
|
|
<div style={{ padding: 20 }}>
|
||
|
|
<div style={{ marginBottom: 24 }}>
|
||
|
|
<h4 style={{ marginBottom: 12 }}>上传说明:</h4>
|
||
|
|
<ul style={{ margin: 0, paddingLeft: 20, color: '#666' }}>
|
||
|
|
<li>请先下载模板文件,按照模板格式填写商品信息</li>
|
||
|
|
<li>支持 .xlsx 和 .xls 格式的Excel文件</li>
|
||
|
|
<li>商品名称和一级分类为必填字段</li>
|
||
|
|
<li>其他字段为选填,可根据实际情况填写</li>
|
||
|
|
<li>来源字段默认为老挝,可选填中国/老挝</li>
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<Upload
|
||
|
|
name="file"
|
||
|
|
accept=".xlsx,.xls"
|
||
|
|
showUploadList={false}
|
||
|
|
beforeUpload={handleBatchImport}
|
||
|
|
disabled={importLoading}
|
||
|
|
>
|
||
|
|
<Button
|
||
|
|
type="primary"
|
||
|
|
icon={<UploadOutlined />}
|
||
|
|
disabled={importLoading}
|
||
|
|
style={{ width: '100%' }}
|
||
|
|
>
|
||
|
|
{importLoading ? '上传中...' : '选择Excel文件'}
|
||
|
|
</Button>
|
||
|
|
</Upload>
|
||
|
|
|
||
|
|
{importLoading && (
|
||
|
|
<div style={{ marginTop: 16 }}>
|
||
|
|
<Progress percent={importProgress} status="active" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<div style={{ marginTop: 24, textAlign: 'center' }}>
|
||
|
|
<Button onClick={handleDownloadTemplate} icon={<DownloadOutlined />}>
|
||
|
|
下载导入模板
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</Modal>
|
||
|
|
|
||
|
|
{/* 分类弹窗 */}
|
||
|
|
<Modal
|
||
|
|
title={editingCategory ? '编辑分类' : '新增分类'}
|
||
|
|
open={categoryModalVisible}
|
||
|
|
onOk={handleSaveCategory}
|
||
|
|
onCancel={() => setCategoryModalVisible(false)}
|
||
|
|
width={500}
|
||
|
|
okText="保存"
|
||
|
|
cancelText="取消"
|
||
|
|
>
|
||
|
|
<Form form={categoryForm} layout="vertical">
|
||
|
|
<Form.Item
|
||
|
|
name="name"
|
||
|
|
label="分类名称"
|
||
|
|
rules={[{ required: true, message: '请输入分类名称' }]}
|
||
|
|
>
|
||
|
|
<Input placeholder="如:电线电缆" />
|
||
|
|
</Form.Item>
|
||
|
|
|
||
|
|
<Form.Item
|
||
|
|
name="level"
|
||
|
|
label="分类级别"
|
||
|
|
rules={[{ required: true, message: '请选择分类级别' }]}
|
||
|
|
>
|
||
|
|
<Select placeholder="选择分类级别">
|
||
|
|
<Select.Option value={1}>一级分类</Select.Option>
|
||
|
|
<Select.Option value={2}>二级分类</Select.Option>
|
||
|
|
</Select>
|
||
|
|
</Form.Item>
|
||
|
|
|
||
|
|
<Form.Item
|
||
|
|
name="parent_id"
|
||
|
|
label="上级分类"
|
||
|
|
extra="选择二级分类时,必须选择上级分类"
|
||
|
|
>
|
||
|
|
<Select
|
||
|
|
placeholder="选择上级分类(可选)"
|
||
|
|
allowClear
|
||
|
|
options={categories
|
||
|
|
.filter(c => c.level === 1)
|
||
|
|
.map(c => ({ label: c.name, value: c.id }))}
|
||
|
|
/>
|
||
|
|
</Form.Item>
|
||
|
|
</Form>
|
||
|
|
</Modal>
|
||
|
|
</div>
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
export default ProductPage
|