Files
SaaS2/src/pages/team/sale/components/AddProductComponent.tsx
RUI eb79e416db
Some checks failed
Next.js CI/CD 流水线 / deploy (push) Failing after 40s
0607.4
2025-06-07 01:41:58 +08:00

767 lines
38 KiB
TypeScript
Raw 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.
/**
* 作者: 阿瑞
* 功能: 产品添加组件
* 版本: 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, JPGPNG
</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;