767 lines
38 KiB
TypeScript
767 lines
38 KiB
TypeScript
/**
|
||
* 作者: 阿瑞
|
||
* 功能: 产品添加组件
|
||
* 版本: 1.0.0
|
||
*/
|
||
import React, { useState, ClipboardEvent, useEffect } from 'react';
|
||
import {
|
||
Form,
|
||
Input,
|
||
Select,
|
||
Button,
|
||
Row,
|
||
Col,
|
||
Tag,
|
||
InputNumber,
|
||
Modal,
|
||
Card,
|
||
Space,
|
||
Typography,
|
||
Tooltip,
|
||
Spin,
|
||
App
|
||
} from 'antd';
|
||
import {
|
||
CloseOutlined,
|
||
PictureOutlined,
|
||
TagOutlined,
|
||
BarcodeOutlined,
|
||
ShoppingOutlined,
|
||
DollarOutlined,
|
||
SaveOutlined,
|
||
InboxOutlined,
|
||
QuestionCircleOutlined,
|
||
ScissorOutlined,
|
||
CarOutlined,
|
||
UploadOutlined
|
||
} from '@ant-design/icons';
|
||
import { Icon } from '@iconify/react';
|
||
import { IBrand, ISupplier, IProduct, ICategory, ITeam } from '@/models/types';
|
||
import { useUserInfo } from '@/store/userStore';
|
||
|
||
const { useApp } = App;
|
||
|
||
const { Option } = Select;
|
||
const { Text } = Typography;
|
||
|
||
interface AddProductProps {
|
||
visible?: boolean;
|
||
onClose?: () => void;
|
||
onSuccess: (product: IProduct) => void;
|
||
}
|
||
|
||
/**
|
||
* 产品添加组件
|
||
* 允许用户创建新产品,包括上传图片、填写产品信息和设置价格
|
||
*/
|
||
const AddProductComponent: React.FC<AddProductProps> = ({ visible, onClose, onSuccess }) => {
|
||
const userInfo = useUserInfo();
|
||
const [form] = Form.useForm();
|
||
const [loading, setLoading] = useState(false);
|
||
const [imageLoading, setImageLoading] = useState(false);
|
||
const { message } = useApp(); // 使用 useApp hook 获取 message 实例
|
||
|
||
// 数据状态
|
||
const [brands, setBrands] = useState<IBrand[]>([]);
|
||
const [suppliers, setSuppliers] = useState<ISupplier[]>([]);
|
||
const [categories, setCategories] = useState<ICategory[]>([]);
|
||
const [productData, setProductData] = useState<Omit<IProduct, '_id' | '供应链报价'>>({
|
||
团队: {} as ITeam,
|
||
编码: '',
|
||
图片: '',
|
||
货号: '',
|
||
级别: '',
|
||
供应商: {} as ISupplier,
|
||
品类: {} as ICategory,
|
||
名称: '',
|
||
描述: '',
|
||
别名: [],
|
||
成本: {
|
||
成本价: 0,
|
||
包装费: 0,
|
||
运费: 0,
|
||
},
|
||
售价: 0,
|
||
库存: 0,
|
||
品牌: {} as IBrand,
|
||
});
|
||
|
||
/**
|
||
* 初始化数据加载
|
||
*/
|
||
useEffect(() => {
|
||
if (visible) {
|
||
fetchInitialData();
|
||
// 重置表单
|
||
form.resetFields();
|
||
setProductData({
|
||
...productData,
|
||
图片: '',
|
||
});
|
||
}
|
||
}, [visible]);
|
||
|
||
/**
|
||
* 获取品牌、类别和供应商数据
|
||
*/
|
||
const fetchInitialData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const teamId = userInfo.团队?._id;
|
||
if (!teamId) {
|
||
throw new Error('团队ID未找到');
|
||
}
|
||
|
||
const [brandsResponse, categoriesResponse, suppliersResponse] = await Promise.all([
|
||
fetch(`/api/backstage/brands?teamId=${teamId}`),
|
||
fetch(`/api/backstage/categories?teamId=${teamId}`),
|
||
fetch(`/api/backstage/suppliers?teamId=${teamId}`)
|
||
]);
|
||
|
||
// 检查响应状态
|
||
if (!brandsResponse.ok || !categoriesResponse.ok || !suppliersResponse.ok) {
|
||
throw new Error('获取数据失败');
|
||
}
|
||
|
||
const [brandsData, categoriesData, suppliersData] = await Promise.all([
|
||
brandsResponse.json(),
|
||
categoriesResponse.json(),
|
||
suppliersResponse.json()
|
||
]);
|
||
|
||
setBrands(brandsData.brands);
|
||
setCategories(categoriesData.categories);
|
||
setSuppliers(suppliersData.suppliers);
|
||
} catch (error) {
|
||
message.error('加载基础数据失败');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 处理剪贴板图片粘贴
|
||
*/
|
||
const handlePaste = async (event: ClipboardEvent<HTMLDivElement>) => {
|
||
event.preventDefault();
|
||
const items = event.clipboardData.items;
|
||
|
||
for (const item of items) {
|
||
if (item.type.indexOf('image') === 0) {
|
||
const file = item.getAsFile();
|
||
if (file) {
|
||
setImageLoading(true);
|
||
|
||
try {
|
||
const reader = new FileReader();
|
||
reader.onload = (event: ProgressEvent<FileReader>) => {
|
||
const base64Image = event.target?.result;
|
||
|
||
// 创建Image对象用于获取图片尺寸和压缩
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
// 确定压缩后的尺寸
|
||
const maxSize = 800;
|
||
const ratio = Math.min(maxSize / img.width, maxSize / img.height, 1);
|
||
const width = Math.floor(img.width * ratio);
|
||
const height = Math.floor(img.height * ratio);
|
||
|
||
// 压缩图片
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = width;
|
||
canvas.height = height;
|
||
const ctx = canvas.getContext('2d');
|
||
ctx?.drawImage(img, 0, 0, width, height);
|
||
|
||
// 转换为较低质量的JPEG
|
||
const compressedImage = canvas.toDataURL('image/jpeg', 0.85);
|
||
|
||
setProductData((prev) => ({
|
||
...prev,
|
||
图片: compressedImage,
|
||
}));
|
||
|
||
setImageLoading(false);
|
||
message.success('图片已添加并优化');
|
||
};
|
||
|
||
img.onerror = () => {
|
||
setImageLoading(false);
|
||
message.error('图片加载失败');
|
||
};
|
||
|
||
img.src = base64Image as string;
|
||
};
|
||
|
||
reader.readAsDataURL(file);
|
||
} catch (error) {
|
||
setImageLoading(false);
|
||
message.error('处理图片失败');
|
||
}
|
||
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 表单提交处理
|
||
*/
|
||
const onFinish = async (values: any) => {
|
||
if (!productData.图片) {
|
||
message.warning('请添加产品图片');
|
||
return;
|
||
}
|
||
|
||
setLoading(true);
|
||
|
||
try {
|
||
// 处理别名 - 从字符串转为数组
|
||
const aliasArray = values.别名 ?
|
||
values.别名.split(',').map((item: string) => item.trim()).filter((item: string) => item) :
|
||
[];
|
||
|
||
const completeData = {
|
||
...values,
|
||
图片: productData.图片,
|
||
团队: userInfo.团队?._id,
|
||
别名: aliasArray,
|
||
成本: {
|
||
成本价: values.成本价 || 0,
|
||
包装费: values.包装费 || 0,
|
||
运费: values.运费 || 0,
|
||
},
|
||
};
|
||
|
||
const response = await fetch('/api/backstage/products', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(completeData)
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const responseData = await response.json();
|
||
const newProduct = responseData.product;
|
||
|
||
message.success({
|
||
content: `产品 ${newProduct.名称} 添加成功`,
|
||
icon: <SaveOutlined style={{ color: '#52c41a' }} />
|
||
});
|
||
|
||
onSuccess(newProduct);
|
||
form.resetFields();
|
||
|
||
// 清空图片
|
||
setProductData(prev => ({
|
||
...prev,
|
||
图片: '',
|
||
}));
|
||
|
||
// 如果提供了关闭函数则调用
|
||
if (onClose) {
|
||
onClose();
|
||
}
|
||
} catch (error) {
|
||
console.error('添加产品失败', error);
|
||
message.error('添加产品失败,请检查数据');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 计算毛利率
|
||
*/
|
||
const calculateGrossProfitMargin = () => {
|
||
const retailPrice = form.getFieldValue('售价') || 0;
|
||
const costPrice = form.getFieldValue('成本价') || 0;
|
||
const packagingCost = form.getFieldValue('包装费') || 0;
|
||
const shippingCost = form.getFieldValue('运费') || 0;
|
||
|
||
const totalCost = costPrice + packagingCost + shippingCost;
|
||
|
||
if (retailPrice <= 0 || totalCost <= 0) {
|
||
return '0.00%';
|
||
}
|
||
|
||
const margin = ((retailPrice - totalCost) / retailPrice) * 100;
|
||
return `${margin.toFixed(2)}%`;
|
||
};
|
||
|
||
/**
|
||
* 表单字段变化处理
|
||
*/
|
||
const handleChange = (name: string, value: any) => {
|
||
setProductData(prev => ({
|
||
...prev,
|
||
[name]: value,
|
||
}));
|
||
};
|
||
|
||
// 毛利率显示
|
||
const grossProfitMargin = Form.useWatch(['售价', '成本价', '包装费', '运费'], form);
|
||
|
||
return (
|
||
<Modal
|
||
title={
|
||
<Space>
|
||
<ShoppingOutlined />
|
||
<span>添加新产品</span>
|
||
</Space>
|
||
}
|
||
open={visible}
|
||
onCancel={onClose}
|
||
width="90%"
|
||
footer={null}
|
||
destroyOnHidden={true}
|
||
maskClosable={false}
|
||
zIndex={1100}
|
||
getContainer={false}
|
||
styles={{
|
||
body: { padding: '16px 24px', maxHeight: '75vh', overflow: 'auto' }
|
||
}}
|
||
>
|
||
<Spin spinning={loading}>
|
||
<div style={{ minHeight: '300px' }}>
|
||
{loading && <div style={{ textAlign: 'center', padding: '50px', color: '#999' }}>加载中...</div>}
|
||
<Form
|
||
form={form}
|
||
layout="vertical"
|
||
onFinish={onFinish}
|
||
requiredMark="optional"
|
||
>
|
||
<Row gutter={24}>
|
||
{/* 左侧表单区域 */}
|
||
<Col span={16}>
|
||
{/* 基本信息 */}
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<ShoppingOutlined />
|
||
<span>基本信息</span>
|
||
</Space>
|
||
}
|
||
size="small"
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Row gutter={24}>
|
||
<Col span={12}>
|
||
<Form.Item
|
||
name="名称"
|
||
label="产品名称"
|
||
rules={[{ required: true, message: '请输入产品名称' }]}
|
||
>
|
||
<Input
|
||
placeholder="输入产品名称"
|
||
prefix={<TagOutlined />}
|
||
maxLength={50}
|
||
showCount
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name="描述" label="产品描述">
|
||
<Input
|
||
placeholder="输入产品描述或备注"
|
||
prefix={<QuestionCircleOutlined />}
|
||
maxLength={100}
|
||
showCount
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
<Row gutter={24}>
|
||
<Col span={12}>
|
||
<Form.Item name="别名" label="别名" help="多个别名请用英文逗号,分隔">
|
||
<Input
|
||
placeholder="输入产品别名,例如:红色款,特价款"
|
||
prefix={<TagOutlined />}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Form.Item name="编码" label="产品编码">
|
||
<Input
|
||
placeholder="输入产品编码"
|
||
prefix={<BarcodeOutlined />}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Form.Item label="货号" name="货号">
|
||
<Input
|
||
placeholder="输入产品货号"
|
||
prefix={<BarcodeOutlined />}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* 分类信息 */}
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<InboxOutlined />
|
||
<span>分类信息</span>
|
||
</Space>
|
||
}
|
||
size="small"
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Row gutter={24}>
|
||
<Col span={8}>
|
||
<Form.Item
|
||
label="产品类别"
|
||
name="品类"
|
||
rules={[{ required: true, message: '请选择产品类别' }]}
|
||
>
|
||
<Select
|
||
onChange={(value) => handleChange('品类', value)}
|
||
placeholder="选择产品类别"
|
||
showSearch
|
||
optionFilterProp="children"
|
||
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
|
||
>
|
||
{categories.map((category) => (
|
||
<Option key={category._id} value={category._id}>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
{category.icon && (
|
||
<Icon icon={category.icon} width={16} height={16} style={{ marginRight: 8 }} />
|
||
)}
|
||
{category.name}
|
||
</div>
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Form.Item
|
||
label="品牌"
|
||
name="品牌"
|
||
rules={[{ required: true, message: '请选择品牌' }]}
|
||
>
|
||
<Select
|
||
onChange={(value) => handleChange('品牌', value)}
|
||
placeholder="选择品牌"
|
||
showSearch
|
||
optionFilterProp="children"
|
||
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
|
||
>
|
||
{brands.map((brand) => (
|
||
<Option key={brand._id} value={brand._id}>
|
||
{brand.name}
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={8}>
|
||
<Form.Item
|
||
label="供应商"
|
||
name="供应商"
|
||
rules={[{ required: true, message: '请选择供应商' }]}
|
||
>
|
||
<Select
|
||
onChange={(value) => handleChange('供应商', value)}
|
||
placeholder="选择供应商"
|
||
showSearch
|
||
optionFilterProp="children"
|
||
getPopupContainer={triggerNode => triggerNode.parentElement || document.body}
|
||
>
|
||
{suppliers.map((supplier) => (
|
||
<Option
|
||
key={supplier._id}
|
||
value={supplier._id}
|
||
disabled={supplier.status === 0}
|
||
>
|
||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||
<span>{supplier.供应商名称}</span>
|
||
{supplier.供应品类.map((category) => (
|
||
<Tag
|
||
key={category._id}
|
||
color="blue"
|
||
style={{
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
marginLeft: 8
|
||
}}
|
||
>
|
||
{category.icon && (
|
||
<Icon
|
||
icon={category.icon}
|
||
width={12}
|
||
height={12}
|
||
style={{ marginRight: 4 }}
|
||
/>
|
||
)}
|
||
{category.name}
|
||
</Tag>
|
||
))}
|
||
</div>
|
||
</Option>
|
||
))}
|
||
</Select>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* 价格信息 */}
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<DollarOutlined />
|
||
<span>价格信息</span>
|
||
</Space>
|
||
}
|
||
size="small"
|
||
>
|
||
<Row gutter={24}>
|
||
<Col span={6}>
|
||
<Form.Item
|
||
label="售价"
|
||
name="售价"
|
||
rules={[{ required: true, message: '请输入产品售价' }]}
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
precision={2}
|
||
style={{ width: '100%' }}
|
||
placeholder="输入产品售价"
|
||
prefix="¥"
|
||
addonAfter="元"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Form.Item
|
||
name="成本价"
|
||
label={
|
||
<Tooltip title="产品本身的采购成本">
|
||
<Space>
|
||
<span>成本价</span>
|
||
<QuestionCircleOutlined style={{ fontSize: 14 }} />
|
||
</Space>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
precision={2}
|
||
style={{ width: '100%' }}
|
||
placeholder="输入成本价格"
|
||
prefix="¥"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Form.Item
|
||
name="包装费"
|
||
label={
|
||
<Tooltip title="外包装、防震材料等费用">
|
||
<Space>
|
||
<span>包装费</span>
|
||
<ScissorOutlined style={{ fontSize: 14 }} />
|
||
</Space>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
precision={2}
|
||
style={{ width: '100%' }}
|
||
placeholder="输入包装费"
|
||
prefix="¥"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={6}>
|
||
<Form.Item
|
||
name="运费"
|
||
label={
|
||
<Tooltip title="物流运输费用">
|
||
<Space>
|
||
<span>运费</span>
|
||
<CarOutlined style={{ fontSize: 14 }} />
|
||
</Space>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
<InputNumber
|
||
min={0}
|
||
precision={2}
|
||
style={{ width: '100%' }}
|
||
placeholder="输入运费"
|
||
prefix="¥"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* 计算毛利率 */}
|
||
{grossProfitMargin && form.getFieldValue('售价') > 0 && (
|
||
<div style={{
|
||
padding: '8px 12px',
|
||
background: '#f6ffed',
|
||
border: '1px solid #b7eb8f',
|
||
borderRadius: '4px',
|
||
marginTop: '8px'
|
||
}}>
|
||
<Space align="center">
|
||
<DollarOutlined style={{ color: '#52c41a' }} />
|
||
<Text>毛利率: </Text>
|
||
<Text strong style={{ color: '#52c41a' }}>
|
||
{calculateGrossProfitMargin()}
|
||
</Text>
|
||
</Space>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
|
||
{/* 右侧图片上传和提交按钮 */}
|
||
<Col span={8}>
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<PictureOutlined />
|
||
<span>产品图片</span>
|
||
</Space>
|
||
}
|
||
size="small"
|
||
style={{ marginBottom: 16 }}
|
||
>
|
||
<Form.Item
|
||
label="图片"
|
||
required
|
||
help="粘贴图片(Ctrl+V)或拖拽图片到此区域"
|
||
>
|
||
<Spin spinning={imageLoading}>
|
||
<div style={{ position: 'relative' }}>
|
||
{imageLoading && (
|
||
<div style={{
|
||
position: 'absolute',
|
||
top: '50%',
|
||
left: '50%',
|
||
transform: 'translate(-50%, -50%)',
|
||
zIndex: 10,
|
||
color: '#999'
|
||
}}>
|
||
处理图片中...
|
||
</div>
|
||
)}
|
||
<div
|
||
onPaste={handlePaste}
|
||
style={{
|
||
cursor: 'pointer',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
alignItems: 'center',
|
||
justifyContent: 'center',
|
||
padding: '16px',
|
||
minHeight: '360px',
|
||
border: '1px dashed #d9d9d9',
|
||
borderRadius: '8px',
|
||
background: '#fafafa',
|
||
transition: 'all 0.3s',
|
||
position: 'relative',
|
||
}}
|
||
onDragOver={(e) => {
|
||
e.preventDefault();
|
||
e.currentTarget.style.borderColor = '#1890ff';
|
||
e.currentTarget.style.background = '#e6f7ff';
|
||
}}
|
||
onDragLeave={(e) => {
|
||
e.preventDefault();
|
||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||
e.currentTarget.style.background = '#fafafa';
|
||
}}
|
||
onMouseOver={(e) => {
|
||
e.currentTarget.style.borderColor = '#1890ff';
|
||
e.currentTarget.style.background = '#e6f7ff';
|
||
}}
|
||
onMouseOut={(e) => {
|
||
e.currentTarget.style.borderColor = '#d9d9d9';
|
||
e.currentTarget.style.background = '#fafafa';
|
||
}}
|
||
>
|
||
{productData.图片 ? (
|
||
<div style={{ position: 'relative', width: '100%', textAlign: 'center' }}>
|
||
<img
|
||
src={productData.图片}
|
||
alt="产品图片"
|
||
style={{
|
||
maxWidth: '100%',
|
||
maxHeight: '360px',
|
||
borderRadius: '4px',
|
||
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||
}}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
danger
|
||
shape="circle"
|
||
icon={<CloseOutlined />}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
setProductData(prev => ({ ...prev, 图片: '' }));
|
||
}}
|
||
style={{
|
||
position: 'absolute',
|
||
top: -10,
|
||
right: -10,
|
||
}}
|
||
size="small"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div style={{ textAlign: 'center' }}>
|
||
<PictureOutlined style={{ fontSize: 48, color: '#bfbfbf' }} />
|
||
<p style={{ marginTop: 16, color: '#666' }}>
|
||
<Space>
|
||
<UploadOutlined />
|
||
<span>点击或粘贴图片到此区域</span>
|
||
</Space>
|
||
</p>
|
||
<p style={{ color: '#bfbfbf', fontSize: 12 }}>
|
||
建议尺寸: 800×800px, 支持 JPG、PNG 格式
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Spin>
|
||
</Form.Item>
|
||
</Card>
|
||
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
icon={<SaveOutlined />}
|
||
onClick={() => form.submit()}
|
||
loading={loading}
|
||
block
|
||
style={{ height: 48 }}
|
||
>
|
||
添加新产品
|
||
</Button>
|
||
</Col>
|
||
</Row>
|
||
</Form>
|
||
</div>
|
||
</Spin>
|
||
</Modal>
|
||
);
|
||
};
|
||
|
||
export default AddProductComponent;
|