910 lines
34 KiB
JavaScript
910 lines
34 KiB
JavaScript
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
Alert,
|
||
Button,
|
||
Empty,
|
||
Image,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popover,
|
||
Select,
|
||
Table,
|
||
Tag,
|
||
Tooltip,
|
||
message,
|
||
} from 'antd';
|
||
import {
|
||
CalculatorOutlined,
|
||
ControlOutlined,
|
||
DownloadOutlined,
|
||
ExportOutlined,
|
||
HistoryOutlined,
|
||
QuestionCircleOutlined,
|
||
ReloadOutlined,
|
||
SaveOutlined,
|
||
SearchOutlined,
|
||
SettingOutlined,
|
||
ShopOutlined,
|
||
} from '@ant-design/icons';
|
||
import { SycmDashboardMetric } from '../../components/Common';
|
||
import { getPlanningAlibaba1688ProductDetail } from '../../api/planningUpload';
|
||
import {
|
||
exportPricingTable,
|
||
fetchPricingHistoryDetail,
|
||
fetchPricingPrefs,
|
||
savePricingHistory,
|
||
savePricingPrefs,
|
||
} from '../../api/pricingCalculator';
|
||
import { computeRow, formatMoney, formatPercent } from './calc';
|
||
import { DiscountInput, MoneyInput, PercentInput } from './inputs';
|
||
import DefaultsConfigModal from './DefaultsConfigModal';
|
||
import FieldConfigDrawer from './FieldConfigDrawer';
|
||
import HistoryDrawer from './HistoryDrawer';
|
||
import {
|
||
applyDropshipDefaultsToRows,
|
||
createDropshipRows,
|
||
DROPSHIP_FIXED_RATES,
|
||
DROPSHIP_SYSTEM_DEFAULTS,
|
||
getDropshipDimensions,
|
||
getDropshipSpecValue,
|
||
mergeDropshipSelectionPricingRows,
|
||
parseAlibaba1688ProductId,
|
||
} from './dropship';
|
||
import {
|
||
createDefaultDropshipFieldConfig,
|
||
DROPSHIP_FIELD_LABELS,
|
||
loadDropshipFieldConfig,
|
||
saveDropshipFieldConfig,
|
||
} from './dropshipFieldConfig';
|
||
import { PERCENT_FIELD_KEYS } from './fieldConfig';
|
||
import './style.css';
|
||
|
||
const DEFAULT_FILTERS = { available: undefined, keyword: '', specs: {} };
|
||
|
||
const LEGEND_ITEMS = [
|
||
{ className: 'is-input', label: '可填写(到手价 / 安装费 / 营业利润率)' },
|
||
{ className: 'is-auto', label: '1688 自动获取 / 固定比例' },
|
||
{ className: 'is-formula', label: '公式' },
|
||
{ className: 'is-pdd', label: '拼多多活动(优惠券 / 折扣可填)' },
|
||
{ className: 'is-danger', label: '营业利润额为负(亏本)' },
|
||
];
|
||
|
||
const ProductThumb = ({ url, size = 36 }) => {
|
||
const [failedUrl, setFailedUrl] = useState('');
|
||
const normalizedUrl = String(url || '').trim().replace(/^\/\//, 'https://');
|
||
|
||
if (!normalizedUrl || failedUrl === normalizedUrl) {
|
||
return (
|
||
<span className="pricing-calc-thumb is-empty" style={{ width: size, height: size }}>
|
||
无图
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<Image
|
||
className="pricing-calc-thumb"
|
||
width={size}
|
||
height={size}
|
||
style={{ objectFit: 'cover' }}
|
||
src={normalizedUrl}
|
||
alt="商品图片"
|
||
referrerPolicy="no-referrer"
|
||
preview={{
|
||
src: normalizedUrl,
|
||
// 1688 图片有防盗链,预览大图也必须以 no-referrer 请求
|
||
imageRender: () => (
|
||
<img
|
||
referrerPolicy="no-referrer"
|
||
src={normalizedUrl}
|
||
alt="商品图片预览"
|
||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||
/>
|
||
),
|
||
}}
|
||
onError={() => setFailedUrl(normalizedUrl)}
|
||
/>
|
||
);
|
||
};
|
||
|
||
const AutoHeaderTitle = ({ children, tip }) => (
|
||
<span className="pricing-calc-th-title">
|
||
{children}
|
||
{tip ? (
|
||
<Tooltip title={tip}>
|
||
<QuestionCircleOutlined />
|
||
</Tooltip>
|
||
) : null}
|
||
</span>
|
||
);
|
||
|
||
const numericSorter = (field) => (leftRow, rightRow) => {
|
||
const left = Number.isFinite(leftRow[field]) ? leftRow[field] : Number.NEGATIVE_INFINITY;
|
||
const right = Number.isFinite(rightRow[field]) ? rightRow[field] : Number.NEGATIVE_INFINITY;
|
||
return left - right;
|
||
};
|
||
|
||
const getFriendlyError = (error, fallback) => (
|
||
error?.response?.data?.error || error?.message || fallback
|
||
);
|
||
|
||
const normalizeRestoredRows = (rows, defaults) => (Array.isArray(rows) ? rows : []).map((row, index) => ({
|
||
...row,
|
||
id: row.id || `1688:restored:${row.skuCode || index}:${index}`,
|
||
specs: Array.isArray(row.specs) ? row.specs : [],
|
||
specsText: row.specsText || (Array.isArray(row.specs)
|
||
? row.specs.map((spec) => `${spec.name}:${spec.value}`).join(' / ')
|
||
: ''),
|
||
price: row.price ?? row.groupPrice ?? null,
|
||
coupon: row.coupon ?? defaults.coupon,
|
||
activityDiscount: row.activityDiscount ?? defaults.activityDiscount,
|
||
installFee: row.installFee ?? defaults.installFee,
|
||
targetMargin: row.targetMargin ?? defaults.targetMargin,
|
||
...DROPSHIP_FIXED_RATES,
|
||
}));
|
||
|
||
const DropshipCalculator = ({
|
||
embedded = false,
|
||
productId: embeddedProductId = '',
|
||
selectionPlanId = '',
|
||
selectionItemId = '',
|
||
selectionRows = [],
|
||
onSelectionPricingSaved,
|
||
} = {}) => {
|
||
const [input, setInput] = useState(embeddedProductId);
|
||
const [productDetail, setProductDetail] = useState(null);
|
||
const [rows, setRows] = useState([]);
|
||
const [searching, setSearching] = useState(false);
|
||
const [searchError, setSearchError] = useState('');
|
||
const [filters, setFilters] = useState(DEFAULT_FILTERS);
|
||
const [fieldConfig, setFieldConfig] = useState(loadDropshipFieldConfig);
|
||
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
|
||
const [defaults, setDefaults] = useState(null);
|
||
const [defaultsLoaded, setDefaultsLoaded] = useState(false);
|
||
const [defaultsOpen, setDefaultsOpen] = useState(false);
|
||
const [batchDiscount, setBatchDiscount] = useState(null);
|
||
const [defaultsSaving, setDefaultsSaving] = useState(false);
|
||
const [exporting, setExporting] = useState(false);
|
||
const [historyOpen, setHistoryOpen] = useState(false);
|
||
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
|
||
const [restoring, setRestoring] = useState(false);
|
||
const [saveModalOpen, setSaveModalOpen] = useState(false);
|
||
const [saveNote, setSaveNote] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const autoLoadedProductRef = React.useRef('');
|
||
const isSelectionPricingMode = Boolean(selectionPlanId && selectionItemId && embeddedProductId);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
fetchPricingPrefs('alibaba1688')
|
||
.then((data) => {
|
||
if (!cancelled) setDefaults(data?.prefs || null);
|
||
})
|
||
.catch(() => {})
|
||
.finally(() => {
|
||
if (!cancelled) setDefaultsLoaded(true);
|
||
});
|
||
return () => { cancelled = true; };
|
||
}, []);
|
||
|
||
const effectiveDefaults = useMemo(
|
||
() => ({ ...DROPSHIP_SYSTEM_DEFAULTS, ...(defaults || {}) }),
|
||
[defaults]
|
||
);
|
||
|
||
const computedRows = useMemo(
|
||
() => rows.map((row) => ({ ...row, ...computeRow(row) })),
|
||
[rows]
|
||
);
|
||
|
||
const dimensions = useMemo(
|
||
() => getDropshipDimensions(productDetail, rows),
|
||
[productDetail, rows]
|
||
);
|
||
|
||
const dimensionOptions = useMemo(() => Object.fromEntries(dimensions.map((dimension) => [
|
||
dimension,
|
||
[...new Set(rows.map((row) => getDropshipSpecValue(row, dimension)).filter(Boolean))]
|
||
.map((value) => ({ value, label: value })),
|
||
])), [dimensions, rows]);
|
||
|
||
const filteredRows = useMemo(() => {
|
||
const keyword = filters.keyword.trim().toLowerCase();
|
||
return computedRows.filter((row) => {
|
||
if (filters.available !== undefined && row.available !== filters.available) return false;
|
||
if (Object.entries(filters.specs).some(([name, value]) => (
|
||
value && getDropshipSpecValue(row, name) !== value
|
||
))) return false;
|
||
if (keyword && ![row.skuCode, row.name, row.specsText]
|
||
.some((value) => String(value || '').toLowerCase().includes(keyword))) return false;
|
||
return true;
|
||
});
|
||
}, [computedRows, filters]);
|
||
|
||
const summaryMetrics = useMemo(() => {
|
||
const margins = filteredRows.map((row) => row.marginRate).filter(Number.isFinite);
|
||
const profits = filteredRows.map((row) => row.profitAmount).filter(Number.isFinite);
|
||
const average = (values) => (values.length
|
||
? values.reduce((sum, value) => sum + value, 0) / values.length
|
||
: null);
|
||
return [
|
||
{ key: 'count', label: '测算 SKU 数', value: `${filteredRows.length}` },
|
||
{ key: 'margin', label: '平均毛利率', value: margins.length ? formatPercent(average(margins), 1) : '--' },
|
||
{ key: 'profit', label: '平均营业利润额', value: profits.length ? `¥${formatMoney(average(profits))}` : '--' },
|
||
{
|
||
key: 'negativeProfit',
|
||
label: '营业利润额为负(亏本)',
|
||
value: `${filteredRows.filter((row) => Number.isFinite(row.profitAmount) && row.profitAmount < 0).length} 行`,
|
||
},
|
||
];
|
||
}, [filteredRows]);
|
||
|
||
const runSearch = useCallback(async (rawInput) => {
|
||
const parsed = parseAlibaba1688ProductId(rawInput);
|
||
if (!parsed.productId) {
|
||
setSearchError(parsed.error);
|
||
return;
|
||
}
|
||
setSearching(true);
|
||
setSearchError('');
|
||
try {
|
||
const response = await getPlanningAlibaba1688ProductDetail(parsed.productId);
|
||
if (response?.success === false) throw new Error(response.error || '1688 商品查询失败');
|
||
const detail = response?.data || response;
|
||
const nextRows = mergeDropshipSelectionPricingRows(
|
||
createDropshipRows(detail, effectiveDefaults, {
|
||
useSourceSkuCode: isSelectionPricingMode,
|
||
}),
|
||
selectionRows
|
||
);
|
||
if (!nextRows.length) {
|
||
throw new Error(`商品「${parsed.productId}」没有可用于测算的 SKU 明细`);
|
||
}
|
||
setInput(parsed.productId);
|
||
setProductDetail(detail);
|
||
setRows(nextRows);
|
||
setFilters(DEFAULT_FILTERS);
|
||
} catch (error) {
|
||
setProductDetail(null);
|
||
setRows([]);
|
||
setSearchError(getFriendlyError(error, '1688 商品查询失败,请稍后重试'));
|
||
} finally {
|
||
setSearching(false);
|
||
}
|
||
}, [effectiveDefaults, isSelectionPricingMode, selectionRows]);
|
||
|
||
useEffect(() => {
|
||
const productId = String(embeddedProductId || '').trim();
|
||
if (!defaultsLoaded || !productId || autoLoadedProductRef.current === productId) return;
|
||
autoLoadedProductRef.current = productId;
|
||
setInput(productId);
|
||
runSearch(productId);
|
||
}, [defaultsLoaded, embeddedProductId, runSearch]);
|
||
|
||
const updateRow = (id, patch) => {
|
||
setRows((current) => current.map((row) => (row.id === id ? { ...row, ...patch } : row)));
|
||
};
|
||
|
||
// 一键批量设置折扣:作用于当前筛选后的全部行
|
||
const applyBatchDiscount = () => {
|
||
if (!batchDiscount || batchDiscount <= 0) return;
|
||
const ids = new Set(filteredRows.map((row) => row.id));
|
||
setRows((current) => current.map((row) => (ids.has(row.id) ? { ...row, activityDiscount: batchDiscount } : row)));
|
||
message.success(`已把 ${ids.size} 行的活动折扣统一设置为 ${batchDiscount}(即 ${batchDiscount * 10} 折)`);
|
||
};
|
||
|
||
const resetToHero = () => {
|
||
setProductDetail(null);
|
||
setRows([]);
|
||
setSearchError('');
|
||
setFilters(DEFAULT_FILTERS);
|
||
};
|
||
|
||
const applyFilter = (patch) => {
|
||
setFilters((current) => ({ ...current, ...patch }));
|
||
};
|
||
|
||
const applySpecFilter = (dimension, value) => {
|
||
setFilters((current) => ({
|
||
...current,
|
||
specs: { ...current.specs, [dimension]: value },
|
||
}));
|
||
};
|
||
|
||
const resetFilters = () => {
|
||
setFilters(DEFAULT_FILTERS);
|
||
};
|
||
|
||
const handleSaveDefaults = async (form) => {
|
||
setDefaultsSaving(true);
|
||
try {
|
||
const data = await savePricingPrefs(form, 'alibaba1688');
|
||
const nextDefaults = { ...DROPSHIP_SYSTEM_DEFAULTS, ...(data?.prefs || form) };
|
||
setRows((current) => applyDropshipDefaultsToRows(current, defaults, nextDefaults));
|
||
setDefaults(data?.prefs || form);
|
||
setDefaultsOpen(false);
|
||
message.success('1688 默认值已保存,未手动修改的行已同步更新');
|
||
} catch (error) {
|
||
message.error(getFriendlyError(error, '保存默认值配置失败'));
|
||
} finally {
|
||
setDefaultsSaving(false);
|
||
}
|
||
};
|
||
|
||
const product = productDetail?.product || productDetail || {};
|
||
const productId = String(productDetail?.productId || product.productId || '');
|
||
const productTitle = productDetail?.title || product.title || '';
|
||
const productMainImage = productDetail?.mainImage || product.mainImage || rows[0]?.imageUrl || '';
|
||
const productSourceUrl = productDetail?.sourceUrl || product.sourceUrl || '';
|
||
|
||
const formulaCell = { className: 'pricing-calc-td--formula' };
|
||
const inputCell = { className: 'pricing-calc-td--input' };
|
||
const pddCell = { className: 'pricing-calc-td--pdd' };
|
||
const pddInputCell = { className: 'pricing-calc-td--pdd-input' };
|
||
const autoHeader = { className: 'pricing-calc-th--auto' };
|
||
const inputHeader = { className: 'pricing-calc-th--input' };
|
||
const formulaHeader = { className: 'pricing-calc-th--formula' };
|
||
const pddHeader = { className: 'pricing-calc-th--pdd' };
|
||
|
||
const columns = [
|
||
{
|
||
title: '图片',
|
||
dataIndex: 'imageUrl',
|
||
width: 64,
|
||
fixed: 'left',
|
||
align: 'center',
|
||
render: (value) => <ProductThumb url={value} />,
|
||
},
|
||
{
|
||
title: '规格组合',
|
||
dataIndex: 'specsText',
|
||
width: 260,
|
||
render: (_, record) => (
|
||
<div className="pricing-calc-spec-list">
|
||
{record.specs.length ? record.specs.map((spec, index) => (
|
||
<span key={`${spec.name}-${spec.value}-${index}`} className="pricing-calc-spec-item">
|
||
<b>{spec.name}</b>
|
||
<span>{spec.value}</span>
|
||
</span>
|
||
)) : <span className="pricing-calc-text">-</span>}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="1688 SKU 采购价,自动获取">采购成本</AutoHeaderTitle>,
|
||
dataIndex: 'cost',
|
||
width: 100,
|
||
align: 'right',
|
||
sorter: numericSorter('cost'),
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="默认按(采购成本 + 运费)/ 0.55 计算,可填写;利润按优惠后实际到手价计算">到手价</AutoHeaderTitle>,
|
||
dataIndex: 'price',
|
||
width: 110,
|
||
sorter: numericSorter('price'),
|
||
onHeaderCell: () => inputHeader,
|
||
onCell: () => inputCell,
|
||
render: (value, record) => (
|
||
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { price: next })} />
|
||
),
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="默认 0,可在默认值中配置">安装费</AutoHeaderTitle>,
|
||
dataIndex: 'installFee',
|
||
width: 96,
|
||
onHeaderCell: () => inputHeader,
|
||
onCell: () => inputCell,
|
||
render: (value, record) => (
|
||
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { installFee: next })} />
|
||
),
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="1688 SKU 运费,自动获取">运费</AutoHeaderTitle>,
|
||
dataIndex: 'shipping',
|
||
width: 96,
|
||
align: 'right',
|
||
sorter: numericSorter('shipping'),
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||
},
|
||
{
|
||
title: '毛利率',
|
||
dataIndex: 'marginRate',
|
||
width: 92,
|
||
align: 'right',
|
||
sorter: numericSorter('marginRate'),
|
||
onHeaderCell: () => formulaHeader,
|
||
onCell: () => formulaCell,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="1688 代发固定使用非黑标店铺,平台佣金 1.5%">平台佣金</AutoHeaderTitle>,
|
||
dataIndex: 'commission',
|
||
width: 96,
|
||
align: 'right',
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="毛利率减去固定费率、目标营业利润率及安装费占比">推广占比</AutoHeaderTitle>,
|
||
dataIndex: 'promoRate',
|
||
width: 104,
|
||
align: 'right',
|
||
sorter: numericSorter('promoRate'),
|
||
onHeaderCell: () => formulaHeader,
|
||
onCell: () => formulaCell,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="固定 2.5%">营销广告费</AutoHeaderTitle>,
|
||
dataIndex: 'adFee',
|
||
width: 100,
|
||
align: 'right',
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="固定 0%">种菜占比</AutoHeaderTitle>,
|
||
dataIndex: 'seeding',
|
||
width: 96,
|
||
align: 'right',
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="固定 5%">售后费用</AutoHeaderTitle>,
|
||
dataIndex: 'afterSale',
|
||
width: 96,
|
||
align: 'right',
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="固定 5%">保险费</AutoHeaderTitle>,
|
||
dataIndex: 'insurance',
|
||
width: 92,
|
||
align: 'right',
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="1688 代发不收取品牌使用费,固定 0%">品牌使用费</AutoHeaderTitle>,
|
||
dataIndex: 'brandFee',
|
||
width: 100,
|
||
align: 'right',
|
||
onHeaderCell: () => autoHeader,
|
||
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="目标营业利润率默认 12%,可填写">营业利润率</AutoHeaderTitle>,
|
||
dataIndex: 'targetMargin',
|
||
width: 100,
|
||
align: 'right',
|
||
onHeaderCell: () => inputHeader,
|
||
onCell: () => inputCell,
|
||
render: (value, record) => (
|
||
<PercentInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { targetMargin: next })} />
|
||
),
|
||
},
|
||
{
|
||
title: '营业利润额',
|
||
dataIndex: 'profitAmount',
|
||
width: 110,
|
||
align: 'right',
|
||
fixed: 'right',
|
||
sorter: numericSorter('profitAmount'),
|
||
onHeaderCell: () => formulaHeader,
|
||
onCell: (record) => ({
|
||
className: Number.isFinite(record.profitAmount) && record.profitAmount < 0
|
||
? 'pricing-calc-td--danger'
|
||
: 'pricing-calc-td--formula',
|
||
}),
|
||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="优惠券金额默认 20,可填写">优惠券金额</AutoHeaderTitle>,
|
||
dataIndex: 'coupon',
|
||
width: 104,
|
||
align: 'right',
|
||
onHeaderCell: () => pddHeader,
|
||
onCell: () => pddInputCell,
|
||
render: (value, record) => (
|
||
<MoneyInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { coupon: next })} />
|
||
),
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="活动折扣默认 0.6(即 6 折),可填写">活动折扣</AutoHeaderTitle>,
|
||
dataIndex: 'activityDiscount',
|
||
width: 96,
|
||
align: 'right',
|
||
onHeaderCell: () => pddHeader,
|
||
onCell: () => pddInputCell,
|
||
render: (value, record) => (
|
||
<DiscountInput style={{ width: '100%' }} value={value} onChange={(next) => updateRow(record.id, { activityDiscount: next })} />
|
||
),
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="公式:(到手价 + 优惠券金额) / 活动折扣">拼单价</AutoHeaderTitle>,
|
||
dataIndex: 'pddPrice',
|
||
width: 100,
|
||
align: 'right',
|
||
fixed: 'right',
|
||
sorter: numericSorter('pddPrice'),
|
||
onHeaderCell: () => pddHeader,
|
||
onCell: () => pddCell,
|
||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||
},
|
||
{
|
||
title: <AutoHeaderTitle tip="拼单价向上取整">最终拼单价</AutoHeaderTitle>,
|
||
dataIndex: 'pddFinalPrice',
|
||
width: 110,
|
||
align: 'right',
|
||
fixed: 'right',
|
||
sorter: numericSorter('pddFinalPrice'),
|
||
onHeaderCell: () => pddHeader,
|
||
onCell: () => pddCell,
|
||
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
|
||
},
|
||
];
|
||
|
||
const columnsByKey = new Map(columns.map((column) => [column.dataIndex, {
|
||
...column,
|
||
key: column.dataIndex,
|
||
}]));
|
||
const visibleColumns = fieldConfig.order
|
||
.filter((key) => !fieldConfig.hidden.includes(key) && columnsByKey.has(key))
|
||
.map((key) => columnsByKey.get(key));
|
||
const tableScrollX = visibleColumns.reduce((sum, column) => sum + (column.width || 100), 0) + 40;
|
||
|
||
const handleExport = async () => {
|
||
if (!productId || filteredRows.length === 0) {
|
||
message.warning('没有可导出的数据');
|
||
return;
|
||
}
|
||
setExporting(true);
|
||
try {
|
||
const exportColumns = visibleColumns.map((column) => ({
|
||
key: column.dataIndex,
|
||
label: DROPSHIP_FIELD_LABELS[column.dataIndex] || column.dataIndex,
|
||
percent: PERCENT_FIELD_KEYS.has(column.dataIndex),
|
||
}));
|
||
const exportRows = filteredRows.map((row) => Object.fromEntries(
|
||
exportColumns.map(({ key }) => [key, row[key]])
|
||
));
|
||
const blob = await exportPricingTable({ spu: productId, columns: exportColumns, rows: exportRows });
|
||
const stamp = new Date();
|
||
const pad = (value) => String(value).padStart(2, '0');
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = `1688代发测算_${productId}_${stamp.getFullYear()}${pad(stamp.getMonth() + 1)}${pad(stamp.getDate())}-${pad(stamp.getHours())}${pad(stamp.getMinutes())}.xlsx`;
|
||
link.click();
|
||
URL.revokeObjectURL(link.href);
|
||
message.success(`已导出 ${filteredRows.length} 行 × ${exportColumns.length} 列`);
|
||
} catch (error) {
|
||
message.error(getFriendlyError(error, '导出失败,请稍后重试'));
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
};
|
||
|
||
const handleSave = async () => {
|
||
setSaving(true);
|
||
try {
|
||
await savePricingHistory({
|
||
calculatorType: 'alibaba1688',
|
||
spuCode: productId,
|
||
selectionContext: isSelectionPricingMode ? {
|
||
planId: selectionPlanId,
|
||
itemId: selectionItemId,
|
||
workflowProfile: 'alibaba_1688_pending',
|
||
} : undefined,
|
||
brand: productDetail?.storeName || product.storeName || '',
|
||
note: saveNote.trim(),
|
||
product: {
|
||
productId,
|
||
title: productTitle,
|
||
mainImage: productMainImage,
|
||
sourceUrl: productSourceUrl,
|
||
storeName: productDetail?.storeName || product.storeName || '',
|
||
category: productDetail?.category || product.category || '',
|
||
skuDimensions: product.skuDimensions || [],
|
||
},
|
||
summary: {
|
||
rowCount: computedRows.length,
|
||
negativeProfitCount: computedRows.filter((row) => (
|
||
Number.isFinite(row.profitAmount) && row.profitAmount < 0
|
||
)).length,
|
||
},
|
||
rows: computedRows,
|
||
});
|
||
setSaveModalOpen(false);
|
||
setHistoryRefreshKey((key) => key + 1);
|
||
if (isSelectionPricingMode && typeof onSelectionPricingSaved === 'function') {
|
||
await onSelectionPricingSaved();
|
||
}
|
||
message.success(isSelectionPricingMode ? '1688 定价表已保存' : '1688 代发测算记录已保存,可在测算历史中还原');
|
||
} catch (error) {
|
||
message.error(getFriendlyError(error, '保存测算记录失败'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
};
|
||
|
||
const handleRestore = async (record) => {
|
||
setRestoring(true);
|
||
try {
|
||
const detail = await fetchPricingHistoryDetail(record.id);
|
||
const payload = detail?.payload;
|
||
if (!payload || payload.calculatorType !== 'alibaba1688' || !Array.isArray(payload.rows)) {
|
||
throw new Error('该记录不是 1688 代发测算记录,无法在当前页签还原');
|
||
}
|
||
const restoredProduct = payload.product || { productId: payload.spuCode || detail.spuCode };
|
||
setProductDetail({
|
||
...restoredProduct,
|
||
productId: restoredProduct.productId || payload.spuCode || detail.spuCode,
|
||
restoredAt: detail.createdAt,
|
||
product: restoredProduct,
|
||
});
|
||
setRows(normalizeRestoredRows(payload.rows, effectiveDefaults));
|
||
setInput(restoredProduct.productId || payload.spuCode || detail.spuCode || '');
|
||
setFilters(DEFAULT_FILTERS);
|
||
setHistoryOpen(false);
|
||
message.success(`已还原商品 ${detail.spuCode} 的测算记录`);
|
||
} catch (error) {
|
||
message.error(getFriendlyError(error, '还原测算记录失败'));
|
||
} finally {
|
||
setRestoring(false);
|
||
}
|
||
};
|
||
|
||
const renderHero = () => (
|
||
<div className="pricing-calc-hero pricing-calc-dropship-hero">
|
||
<div className="pricing-calc-hero__icon">
|
||
<ShopOutlined />
|
||
</div>
|
||
<h2 className="pricing-calc-hero__title">1688代发拼多多测算</h2>
|
||
<p className="pricing-calc-hero__subtitle">
|
||
输入 1688 商品 ID 或商品详情链接,读取全部 SKU 的采购价、规格、图片与运费,按非黑标拼多多店铺规则完成售价与利润测算。
|
||
</p>
|
||
<Input.Search
|
||
className="pricing-calc-hero__search"
|
||
size="large"
|
||
placeholder="输入商品 ID 或 detail.1688.com/offer/... 链接"
|
||
enterButton="开始测算"
|
||
loading={searching}
|
||
value={input}
|
||
readOnly={isSelectionPricingMode}
|
||
onChange={(event) => setInput(event.target.value)}
|
||
onSearch={runSearch}
|
||
/>
|
||
{searchError ? (
|
||
<Alert className="pricing-calc-hero__error" type="warning" showIcon message={searchError} />
|
||
) : null}
|
||
<div className="pricing-calc-hero__footer">
|
||
<span className="pricing-calc-hero__hint">平台佣金 1.5% · 营销广告 2.5% · 营业利润率默认 12%</span>
|
||
{!embedded ? <Button type="link" icon={<HistoryOutlined />} loading={restoring} onClick={() => setHistoryOpen(true)}>
|
||
测算历史
|
||
</Button> : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const renderResult = () => (
|
||
<div>
|
||
<div className="pricing-calc-spubar pricing-calc-dropship-spubar">
|
||
<div className="pricing-calc-product-summary">
|
||
<ProductThumb url={productMainImage} size={52} />
|
||
<div className="pricing-calc-product-summary__body">
|
||
<Tooltip title={productTitle}>
|
||
<strong className="pricing-calc-product-summary__title">{productTitle || '1688 商品'}</strong>
|
||
</Tooltip>
|
||
<div className="pricing-calc-spubar__meta">
|
||
<Tag color="orange" className="pricing-calc-spubar__spu">{productId}</Tag>
|
||
<span>{rows.length} 个 SKU</span>
|
||
<span>{dimensions.length} 个规格组</span>
|
||
{productDetail?.storeName || product.storeName ? (
|
||
<span>厂家:{productDetail?.storeName || product.storeName}</span>
|
||
) : null}
|
||
{productDetail?.restoredAt ? <span>历史还原</span> : null}
|
||
<span>佣金 1.5%</span>
|
||
<span>种菜 0%</span>
|
||
<span>品牌费 0%</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="pricing-calc-spubar__content">
|
||
<div className="pricing-calc-spubar__controls">
|
||
<Input.Search
|
||
className="pricing-calc-spubar__search"
|
||
placeholder="商品 ID 或 1688 商品链接"
|
||
enterButton="测算"
|
||
loading={searching}
|
||
value={input}
|
||
readOnly={isSelectionPricingMode}
|
||
onChange={(event) => setInput(event.target.value)}
|
||
onSearch={runSearch}
|
||
/>
|
||
</div>
|
||
<div className="pricing-calc-spubar__actions">
|
||
{!embedded ? <Button icon={<ReloadOutlined />} onClick={resetToHero}>新的测算</Button> : null}
|
||
{!embedded ? <Button icon={<HistoryOutlined />} loading={restoring} onClick={() => setHistoryOpen(true)}>测算历史</Button> : null}
|
||
<Popover
|
||
trigger="click"
|
||
placement="bottomRight"
|
||
title="批量设置活动折扣"
|
||
content={(
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||
<InputNumber
|
||
min={0.01}
|
||
max={1}
|
||
step={0.05}
|
||
precision={2}
|
||
placeholder="如 0.6 = 6 折"
|
||
style={{ width: 120 }}
|
||
value={batchDiscount}
|
||
onChange={(value) => setBatchDiscount(value)}
|
||
/>
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
disabled={!batchDiscount || batchDiscount <= 0 || filteredRows.length === 0}
|
||
onClick={applyBatchDiscount}
|
||
>
|
||
应用到全部行({filteredRows.length})
|
||
</Button>
|
||
</div>
|
||
)}
|
||
>
|
||
<Button icon={<CalculatorOutlined />}>批量折扣</Button>
|
||
</Popover>
|
||
<Button icon={<ControlOutlined />} onClick={() => setDefaultsOpen(true)}>默认值</Button>
|
||
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExport}>导出</Button>
|
||
<Button icon={<SettingOutlined />} onClick={() => setFieldConfigOpen(true)}>字段配置</Button>
|
||
{productSourceUrl ? (
|
||
<Button icon={<ExportOutlined />} href={productSourceUrl} target="_blank" rel="noreferrer">查看 1688 商品</Button>
|
||
) : null}
|
||
<Button
|
||
type="primary"
|
||
icon={<SaveOutlined />}
|
||
disabled={rows.length === 0}
|
||
onClick={() => {
|
||
setSaveNote('');
|
||
setSaveModalOpen(true);
|
||
}}
|
||
>
|
||
{isSelectionPricingMode ? '保存定价表' : '保存测算记录'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<div className="pricing-calc-summary" aria-label="1688 代发定价测算摘要">
|
||
{summaryMetrics.map((item) => (
|
||
<SycmDashboardMetric key={item.key} label={item.label} value={item.value} help={false} />
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pricing-calc-filter">
|
||
<span className="pricing-calc-filter__item">
|
||
<label>采购状态</label>
|
||
<Select
|
||
allowClear
|
||
style={{ width: 112 }}
|
||
placeholder="全部"
|
||
value={filters.available}
|
||
options={[
|
||
{ value: true, label: '可采购' },
|
||
{ value: false, label: '不可采购' },
|
||
]}
|
||
onChange={(value) => applyFilter({ available: value })}
|
||
/>
|
||
</span>
|
||
{dimensions.map((dimension) => (
|
||
<span key={dimension} className="pricing-calc-filter__item">
|
||
<label>{dimension}</label>
|
||
<Select
|
||
allowClear
|
||
showSearch
|
||
optionFilterProp="label"
|
||
style={{ width: 150 }}
|
||
placeholder="全部"
|
||
value={filters.specs[dimension]}
|
||
options={dimensionOptions[dimension] || []}
|
||
onChange={(value) => applySpecFilter(dimension, value)}
|
||
/>
|
||
</span>
|
||
))}
|
||
<Input
|
||
allowClear
|
||
className="pricing-calc-filter__keyword"
|
||
prefix={<SearchOutlined />}
|
||
placeholder="SKU 名称 / 规格"
|
||
value={filters.keyword}
|
||
onChange={(event) => applyFilter({ keyword: event.target.value })}
|
||
/>
|
||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>重置</Button>
|
||
<span className="pricing-calc-legend">
|
||
{LEGEND_ITEMS.map((item) => (
|
||
<span key={item.className} className="pricing-calc-legend__item">
|
||
<i className={`pricing-calc-chip ${item.className}`} />
|
||
{item.label}
|
||
</span>
|
||
))}
|
||
</span>
|
||
</div>
|
||
|
||
<Table
|
||
className="pricing-calc-table"
|
||
rowKey="id"
|
||
size="small"
|
||
columns={visibleColumns}
|
||
dataSource={filteredRows}
|
||
scroll={{ x: tableScrollX, scrollToFirstRowOnChange: true }}
|
||
pagination={false}
|
||
locale={{ emptyText: <Empty description="没有匹配的 SKU,请调整筛选条件" /> }}
|
||
/>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{productDetail ? renderResult() : renderHero()}
|
||
|
||
<Modal
|
||
title={isSelectionPricingMode ? '保存 1688 上架定价表' : '保存 1688 代发测算记录'}
|
||
open={saveModalOpen}
|
||
okText="保存"
|
||
cancelText="取消"
|
||
confirmLoading={saving}
|
||
onOk={handleSave}
|
||
onCancel={() => setSaveModalOpen(false)}
|
||
>
|
||
<p className="pricing-calc-save__hint">
|
||
{isSelectionPricingMode
|
||
? `将保存商品「${productId}」共 ${rows.length} 行定价,并写回当前上架任务。`
|
||
: `将保存商品「${productId}」共 ${rows.length} 行的完整测算快照,仅自己可见。`}
|
||
</p>
|
||
<Input
|
||
value={saveNote}
|
||
maxLength={200}
|
||
placeholder="备注(可选),如:首轮代发定价"
|
||
onChange={(event) => setSaveNote(event.target.value)}
|
||
/>
|
||
</Modal>
|
||
|
||
<DefaultsConfigModal
|
||
open={defaultsOpen}
|
||
onClose={() => setDefaultsOpen(false)}
|
||
defaults={defaults}
|
||
systemDefaults={DROPSHIP_SYSTEM_DEFAULTS}
|
||
saving={defaultsSaving}
|
||
onSave={handleSaveDefaults}
|
||
title="1688 代发默认值配置"
|
||
/>
|
||
<FieldConfigDrawer
|
||
open={fieldConfigOpen}
|
||
onClose={() => setFieldConfigOpen(false)}
|
||
config={fieldConfig}
|
||
fieldLabels={DROPSHIP_FIELD_LABELS}
|
||
createDefaultConfig={createDefaultDropshipFieldConfig}
|
||
onChange={(next) => {
|
||
setFieldConfig(next);
|
||
saveDropshipFieldConfig(next);
|
||
}}
|
||
/>
|
||
<HistoryDrawer
|
||
open={historyOpen}
|
||
onClose={() => setHistoryOpen(false)}
|
||
onRestore={handleRestore}
|
||
refreshKey={historyRefreshKey}
|
||
calculatorType="alibaba1688"
|
||
title="1688 代发测算历史"
|
||
identityLabel="商品 ID"
|
||
/>
|
||
</>
|
||
);
|
||
};
|
||
|
||
export default DropshipCalculator;
|