feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+242
View File
@@ -0,0 +1,242 @@
import { useEffect, useState } from 'react';
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd';
import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
import { getFxRate } from '@/services/fx';
import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import FieldLabel, { fieldRowStyle } from './FieldLabel';
const { Text } = Typography;
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingPayload = Record<string, any>;
/** 包装信息 → 计价输入:重量统一 g */
function weightGrams(product: ProductDetail): number {
const w = product.weight ?? 0;
return product.weight_unit === 'kg' ? w * 1000 : w;
}
/** 包装信息 → 计价输入:尺寸统一 cm */
function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1; // mm → cm
return {
l: (product.depth ?? 0) * scale,
w: (product.width ?? 0) * scale,
h: (product.height ?? 0) * scale,
};
}
/**
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/预留折扣)。
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 预留折扣前价格)。
*/
export default function PriceInfoPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
const [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
useEffect(() => {
if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 汇率:未快照时拉取;已有计价记录的则跟随重算
useEffect(() => {
if (!fxRate) {
getFxRate()
.then((r) => {
setFxRate(r.rate);
if (pricing.calculatedAt) recalc({ fxRate: r.rate });
})
.catch((e) => message.error(apiErrorMessage(e)));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const tdPrice = pricing.tdPrice ?? 3;
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level,
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
discountReserve: patch.reserve ?? reserve,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; reserve: number; fxRate: number }> = {}) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
level: patch.level ?? level,
reserve: patch.reserve ?? reserve,
fxRate: patch.fxRate ?? fxRate,
};
const r = compute(patch);
onSave({
pricing: {
...pricing,
purchasePrice: p.purchasePrice,
profitRate: p.profitRate,
logisticsLevel: p.level,
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
discountReserve: p.reserve,
fxRate: p.fxRate,
logisticsFee: r.logisticsFee,
fullCommission: r.fullCommission,
totalCost: r.totalCost,
sellingPriceCny: r.sellingPriceCny,
sellingPriceRub: r.sellingPriceRub,
calculatedAt: new Date().toISOString(),
},
fx_rate: p.fxRate,
price: r.sellingPriceCny,
old_price: r.reservedPriceCny,
currency_code: 'CNY',
});
};
const preview = fxRate ? compute() : null;
const hint = preview ? validateLogisticsLevel(preview.sellingPriceRub, level) : '';
return (
<div>
{/* 售价 / 划线价 / 币种 */}
<Row gutter={16} style={fieldRowStyle}>
<Col span={6}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={product.price ?? undefined}
onChange={(v) => onSave({ price: v ?? null, currency_code: 'CNY' })}
/>
</Col>
<Col span={6}>
<FieldLabel>线 ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={product.old_price ?? undefined}
onChange={(v) => onSave({ old_price: v ?? null })}
/>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>CNY</Text>
</Col>
<Col span={6}>
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
/线
</Text>
</Col>
</Row>
{/* 定价参数 */}
<Row gutter={16} style={fieldRowStyle}>
<Col span={5}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={purchasePrice}
onChange={(v) => {
setPurchasePrice(v ?? 0);
recalc({ purchasePrice: v ?? 0 });
}}
/>
</Col>
<Col span={5}>
<FieldLabel> %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={profitRate}
onChange={(v) => {
setProfitRate(v ?? 0);
recalc({ profitRate: v ?? 0 });
}}
/>
</Col>
<Col span={5}>
<FieldLabel> ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={fxRate}
onChange={(v) => {
setFxRate(v ?? 0);
recalc({ fxRate: v ?? 0 });
}}
/>
</Col>
<Col span={5}>
<FieldLabel> %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={95}
value={reserve}
onChange={(v) => {
setReserve(v ?? 0);
recalc({ reserve: v ?? 0 });
}}
/>
</Col>
<Col span={4} style={{ alignSelf: 'flex-end', paddingBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
/
</Text>
</Col>
</Row>
{/* 物流等级 */}
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Radio.Group
block
optionType="button"
buttonStyle="solid"
value={level}
onChange={(e) => {
setLevel(e.target.value);
recalc({ level: e.target.value });
}}
options={[
{ value: 'low', label: '低 (low)' },
{ value: 'high', label: '高 (high)' },
{ value: 'high2', label: 'Premium (high2)' },
]}
/>
</div>
{/* 实时结果摘要 */}
{preview && (
<Space wrap>
<Tag> ¥ {preview.totalCost.toFixed(2)}</Tag>
<Tag> ¥ {preview.logisticsFee.toFixed(2)}</Tag>
<Tag> {preview.sellingPriceRub.toFixed(0)}</Tag>
{hint && <Tag color="orange">{hint}</Tag>}
</Space>
)}
</div>
);
}