1330 lines
51 KiB
JavaScript
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.

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Alert,
Button,
Checkbox,
Empty,
Form,
Image,
Input,
InputNumber,
Modal,
Popconfirm,
Popover,
Select,
Table,
Tabs,
Tag,
Tooltip,
message,
} from 'antd';
import {
CalculatorOutlined,
ControlOutlined,
DeleteOutlined,
DownloadOutlined,
HistoryOutlined,
HomeOutlined,
QuestionCircleOutlined,
ReloadOutlined,
SaveOutlined,
SearchOutlined,
SettingOutlined,
ShopOutlined,
} from '@ant-design/icons';
import { SycmBusinessPanel, SycmDashboardMetric, SycmPage } from '../../components/Common';
import {
createRowFromSku,
createRowId,
formatSourceLabel,
getPlatformCommissionRate,
inferBlackLabelShop,
SYSTEM_DEFAULTS,
} from './constants';
import { computeRow, formatMoney, formatPercent } from './calc';
import { DiscountInput, MoneyInput, PercentInput } from './inputs';
import DefaultsConfigModal from './DefaultsConfigModal';
import DropshipCalculator from './DropshipCalculator';
import FieldConfigDrawer from './FieldConfigDrawer';
import HistoryDrawer from './HistoryDrawer';
import { FIELD_LABELS, PERCENT_FIELD_KEYS, loadFieldConfig, saveFieldConfig } from './fieldConfig';
import {
exportPricingTable,
fetchProductCodeMeta,
fetchPricingHistoryDetail,
fetchPricingPrefs,
fetchSpuSkus,
savePricingHistory,
savePricingPrefs,
} from '../../api/pricingCalculator';
import './style.css';
const KEYWORD_FIELDS = ['barcode', 'name', 'size', 'color'];
const PRODUCT_CODE_CONFIG_FIELDS = [
{ key: 'taxStatus', label: '税务状态' },
{ key: 'invoiceType', label: '发票类型' },
{ key: 'installationService', label: '安装服务' },
{ key: 'installationMode', label: '安装方式' },
];
const LEGEND_ITEMS = [
{ className: 'is-input', label: '可填写(到手价 / 安装费 / 营业利润率)' },
{ className: 'is-auto', label: '自动获取 / 固定比例' },
{ className: 'is-formula', label: '公式' },
{ className: 'is-pdd', label: '拼多多活动(优惠券 / 折扣可填)' },
{ className: 'is-danger', label: '营业利润额为负(亏本)' },
];
const ProductThumb = ({ url }) => {
const [broken, setBroken] = useState(false);
if (!url || broken) {
return <span className="pricing-calc-thumb is-empty">无图</span>;
}
return (
<Image
className="pricing-calc-thumb"
width={36}
height={36}
style={{ objectFit: 'cover' }}
src={url}
alt="商品图片"
preview={{ src: url }}
onError={() => setBroken(true)}
/>
);
};
const AutoHeaderTitle = ({ children, tip }) => (
<span className="pricing-calc-th-title">
{children}
{tip ? (
<Tooltip title={tip}>
<QuestionCircleOutlined />
</Tooltip>
) : null}
</span>
);
const SKU_STATUS_COLORS = {
销售: 'green',
退市: 'orange',
停产: 'red',
};
const SKU_STATUS_ORDER = ['销售', '退市', '停产'];
const SkuStatusTag = ({ status }) => (
status
? (
<Tag
color={SKU_STATUS_COLORS[status] || 'blue'}
style={{ minWidth: 44, marginInlineEnd: 0, textAlign: 'center' }}
>
{status}
</Tag>
)
: <span className="pricing-calc-text">--</span>
);
const formatStock = (value) => {
if (value === null || value === undefined || value === '') return '--';
const numeric = Number(value);
return Number.isFinite(numeric)
? numeric.toLocaleString('zh-CN', { maximumFractionDigits: 2 })
: '--';
};
const StockCell = ({ value, warehouses }) => {
const rows = Array.isArray(warehouses) ? warehouses : [];
const displayValue = formatStock(value);
if (rows.length === 0) {
return <span className="pricing-calc-value">{displayValue}</span>;
}
return (
<Popover
trigger="hover"
placement="right"
content={(
<div className="pricing-calc-sku-pop">
{rows.map((warehouse, index) => (
<div
key={warehouse.warehouseId || `${warehouse.warehouseName}-${index}`}
>
<div>{warehouse.warehouseName || '未命名仓库'}:{formatStock(warehouse.stock)}</div>
{warehouse.warehouseType ? (
<div>{warehouse.warehouseType}</div>
) : null}
</div>
))}
</div>
)}
>
<span className="pricing-calc-value">{displayValue}</span>
</Popover>
);
};
const DEFAULT_FILTERS = { status: undefined, color: undefined, size: undefined, keyword: '' };
const valueOrDefaultWhenMissing = (row, field, fallback) => (
Object.prototype.hasOwnProperty.call(row, field) ? row[field] : fallback
);
const PricingCalculator = ({
embedded = false,
selectionPlanId: embeddedPlanId = '',
selectionItemId: embeddedItemId = '',
selectionSpu: embeddedSpu = '',
onSelectionPricingSaved,
} = {}) => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const selectionPlanId = String(embeddedPlanId || searchParams.get('planId') || '');
const selectionItemId = String(embeddedItemId || searchParams.get('itemId') || '');
const selectionSpu = String(embeddedSpu || searchParams.get('spu') || '').trim().toUpperCase();
const isSelectionPricingMode = Boolean(selectionPlanId && selectionItemId && selectionSpu);
const autoLoadedRef = useRef(false);
const tableRegionRef = useRef(null);
const [spuInput, setSpuInput] = useState('');
const [spuData, setSpuData] = useState(null); // { spu, source, warnings, freightProfile, restoredAt? }
const [rows, setRows] = useState([]);
const [searching, setSearching] = useState(false);
const [searchError, setSearchError] = useState('');
const [filters, setFilters] = useState(DEFAULT_FILTERS);
const [pagination, setPagination] = useState({ current: 1, pageSize: 20 });
const [embeddedTableScrollY, setEmbeddedTableScrollY] = useState(320);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
const [saveModalOpen, setSaveModalOpen] = useState(false);
const [saveNote, setSaveNote] = useState('');
const [saving, setSaving] = useState(false);
const [restoring, setRestoring] = useState(false);
const [fieldConfig, setFieldConfig] = useState(loadFieldConfig);
const [fieldConfigOpen, setFieldConfigOpen] = useState(false);
const [exporting, setExporting] = 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 [selectionContext, setSelectionContext] = useState(null);
const [productCodeForm] = Form.useForm();
const [productCodeMeta, setProductCodeMeta] = useState(null);
const [productCodeMetaLoading, setProductCodeMetaLoading] = useState(false);
const [isBlackLabelShop, setIsBlackLabelShop] = useState(false);
const [calculatorMode, setCalculatorMode] = useState('company');
useEffect(() => {
let cancelled = false;
fetchPricingPrefs()
.then((data) => {
if (!cancelled) setDefaults(data?.prefs || null);
})
.catch(() => {})
.finally(() => {
if (!cancelled) setDefaultsLoaded(true);
});
return () => { cancelled = true; };
}, []);
const computedRows = useMemo(
() => rows.map((row) => ({ ...row, ...computeRow(row) })),
[rows]
);
const isAlibaba1688Selection = selectionContext?.workflowProfile === 'alibaba_1688_pending';
const productCodeCompleted = selectionContext?.productCodeStatus === 'completed';
const colorOptions = useMemo(
() => [...new Set(rows.map((row) => row.color).filter(Boolean))]
.map((value) => ({ value, label: value })),
[rows]
);
const sizeOptions = useMemo(
() => [...new Set(rows.map((row) => row.size).filter(Boolean))]
.map((value) => ({ value, label: value })),
[rows]
);
const statusOptions = useMemo(
() => [...new Set(rows.map((row) => row.skuStatus).filter(Boolean))]
.sort((left, right) => {
const leftIndex = SKU_STATUS_ORDER.indexOf(left);
const rightIndex = SKU_STATUS_ORDER.indexOf(right);
if (leftIndex === -1 && rightIndex === -1) return left.localeCompare(right, 'zh-CN');
if (leftIndex === -1) return 1;
if (rightIndex === -1) return -1;
return leftIndex - rightIndex;
})
.map((value) => ({ value, label: value })),
[rows]
);
const filteredRows = useMemo(() => {
const keyword = (filters.keyword || '').trim().toLowerCase();
return computedRows.filter((row) => {
if (filters.status && row.skuStatus !== filters.status) return false;
if (filters.color && row.color !== filters.color) return false;
if (filters.size && row.size !== filters.size) return false;
if (keyword && !KEYWORD_FIELDS.some((field) => String(row[field] || '').toLowerCase().includes(keyword))) {
return false;
}
return true;
});
}, [computedRows, filters]);
const summaryMetrics = useMemo(() => {
const margins = filteredRows.map((row) => row.marginRate).filter((value) => value !== null);
const profits = filteredRows.map((row) => row.profitAmount).filter((value) => value !== null);
const negativeProfit = filteredRows.filter((row) => row.profitAmount !== null && row.profitAmount < 0).length;
const average = (list) => (list.length ? list.reduce((sum, value) => sum + value, 0) / list.length : null);
return [
{ key: 'count', label: '测算条码数', 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: `${negativeProfit} 行` },
];
}, [filteredRows]);
useEffect(() => {
if (!embedded || !tableRegionRef.current) return undefined;
const tableRegion = tableRegionRef.current;
let frameId = 0;
const updateTableHeight = () => {
window.cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
const paginationElement = tableRegion.querySelector('.ant-pagination');
const paginationHeight = paginationElement?.getBoundingClientRect().height || 32;
const availableHeight = Math.floor(
window.innerHeight
- tableRegion.getBoundingClientRect().top
- paginationHeight
- 64
);
const nextHeight = Math.max(180, availableHeight);
setEmbeddedTableScrollY((current) => (current === nextHeight ? current : nextHeight));
});
};
updateTableHeight();
window.addEventListener('resize', updateTableHeight);
const resizeObserver = typeof ResizeObserver === 'function'
? new ResizeObserver(updateTableHeight)
: null;
resizeObserver?.observe(tableRegion.parentElement || tableRegion);
return () => {
window.cancelAnimationFrame(frameId);
window.removeEventListener('resize', updateTableHeight);
resizeObserver?.disconnect();
};
}, [embedded, filteredRows.length, pagination.pageSize, spuData?.warnings?.length]);
const updateRow = (id, patch) => {
setRows((prev) => prev.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((prev) => prev.map((row) => (ids.has(row.id) ? { ...row, activityDiscount: batchDiscount } : row)));
message.success(`已把 ${ids.size} 行的活动折扣统一设置为 ${batchDiscount}(即 ${batchDiscount * 10} 折)`);
};
const removeRow = (record) => {
setRows((previous) => previous.filter((row) => row.id !== record.id));
setPagination((previous) => ({ ...previous, current: 1 }));
message.success(`已移除条码 ${record.barcode},不会参与测算或保存`);
};
const runSearch = useCallback(async (rawSpu) => {
const spu = String(rawSpu || '').trim().toUpperCase();
if (!spu) {
setSearchError('请输入公司 SPU');
return;
}
setSearching(true);
setSearchError('');
try {
const data = await fetchSpuSkus(spu, selectionPlanId && selectionItemId ? {
planId: selectionPlanId,
itemId: selectionItemId,
} : {});
setSpuData({
spu: data.spu,
source: data.source,
warnings: data.warnings || [],
freightProfile: data.freightProfile || null,
});
setSelectionContext(data.selectionContext || null);
const sourceRows = data.rows || [];
const hasSavedCommission = sourceRows.some((sku) => {
const value = sku?.savedPricing?.commission;
return value !== null && value !== undefined && value !== '' && Number.isFinite(Number(value));
});
const nextIsBlackLabelShop = hasSavedCommission
? inferBlackLabelShop(sourceRows)
: isBlackLabelShop;
setIsBlackLabelShop(nextIsBlackLabelShop);
setRows(sourceRows.map((sku) => createRowFromSku(sku, {
freightProfile: data.freightProfile,
defaults,
commission: getPlatformCommissionRate(nextIsBlackLabelShop),
})));
setFilters(DEFAULT_FILTERS);
setPagination((prev) => ({ ...prev, current: 1 }));
} catch (error) {
setSpuData(null);
setSelectionContext(null);
setRows([]);
setSearchError(error.message || 'SPU 查询失败,请稍后重试');
} finally {
setSearching(false);
}
}, [selectionItemId, selectionPlanId, defaults, isBlackLabelShop]);
useEffect(() => {
if (!defaultsLoaded || autoLoadedRef.current || !selectionPlanId || !selectionItemId || !selectionSpu) return;
autoLoadedRef.current = true;
setSpuInput(selectionSpu);
runSearch(selectionSpu);
}, [defaultsLoaded, runSearch, selectionItemId, selectionPlanId, selectionSpu]);
const resetToHero = () => {
setSpuData(null);
setRows([]);
setSearchError('');
setSelectionContext(null);
setFilters(DEFAULT_FILTERS);
setIsBlackLabelShop(false);
};
const handleBlackLabelShopChange = (checked) => {
const commission = getPlatformCommissionRate(checked);
setIsBlackLabelShop(checked);
setRows((previous) => previous.map((row) => ({ ...row, commission })));
};
const openSaveModal = async () => {
setSaveNote('');
if (isAlibaba1688Selection) {
setProductCodeMetaLoading(true);
try {
const meta = productCodeMeta || await fetchProductCodeMeta();
setProductCodeMeta(meta);
const savedConfig = selectionContext?.productCodeConfig || {};
productCodeForm.setFieldsValue({
operationCenter: savedConfig.operationCenter || meta?.operationCenters?.[0],
categoryName: savedConfig.categoryName || meta?.categories?.[0],
...Object.fromEntries(PRODUCT_CODE_CONFIG_FIELDS.map(({ key }) => [
key,
savedConfig[key] || meta?.tagOptions?.[key]?.[0],
])),
});
} catch (error) {
message.error(error.message || '公司SKU编码配置加载失败');
return;
} finally {
setProductCodeMetaLoading(false);
}
}
setSaveModalOpen(true);
};
const handleSave = async () => {
setSaving(true);
try {
const productCodeConfig = isAlibaba1688Selection
? await productCodeForm.validateFields()
: undefined;
if (isAlibaba1688Selection && rows.some((row) => !String(row.selectedSkuName || row.name || '').trim())) {
throw new Error('每个SKU都必须确认最终名称后才能申请公司编码');
}
const rowBrand = rows.find((row) => row.brand)?.brand || '';
const saved = await savePricingHistory({
spuCode: spuData.spu,
isBlackLabelShop,
selectionContext,
brand: rowBrand,
note: saveNote.trim(),
productCodeConfig,
summary: {
rowCount: computedRows.length,
negativeProfitCount: computedRows.filter((row) => row.profitAmount !== null && row.profitAmount < 0).length,
},
freightProfile: spuData.freightProfile,
rows: computedRows,
});
if (saved.selectionPricing) {
setSelectionContext((current) => ({
...current,
...saved.selectionPricing,
}));
message.success(isAlibaba1688Selection
? '定价表已保存,公司SKU编码已按当前名称与顺序回写'
: '定价表已保存,拼单价将作为自动上架SKU最终定价');
} else {
message.success('测算记录已保存,可在「测算历史」中还原');
}
setSaveModalOpen(false);
setHistoryRefreshKey((key) => key + 1);
if (saved.selectionPricing && typeof onSelectionPricingSaved === 'function') {
await onSelectionPricingSaved(saved.selectionPricing);
}
} catch (error) {
message.error(error.message || '保存测算记录失败');
} finally {
setSaving(false);
}
};
// 导出当前页面:列按字段配置的最新显示顺序,行为筛选后的全部数据
const handleSaveDefaults = async (form) => {
setDefaultsSaving(true);
try {
const data = await savePricingPrefs(form);
const nextDefaults = { ...SYSTEM_DEFAULTS, ...(data?.prefs || form) };
// 页面中仍停留在"旧默认值"的字段同步换成新默认值;手动改过的值不动
const oldDefaults = { ...SYSTEM_DEFAULTS, ...(defaults || {}) };
const syncKeys = ['coupon', 'activityDiscount', 'installFee', 'targetMargin'];
setRows((prev) => prev.map((row) => {
const patch = {};
syncKeys.forEach((key) => {
const current = row[key];
const currentNum = current === null || current === undefined || current === ''
? null : Number(current);
if (currentNum !== null && Number.isFinite(currentNum)
&& Math.abs(currentNum - oldDefaults[key]) < 1e-9
&& Math.abs(nextDefaults[key] - oldDefaults[key]) >= 1e-9) {
patch[key] = nextDefaults[key];
}
});
return Object.keys(patch).length > 0 ? { ...row, ...patch } : row;
}));
setDefaults(data?.prefs || form);
setDefaultsOpen(false);
message.success('默认值配置已保存,页面中未手动修改的行已同步更新');
} catch (error) {
message.error(error.message || '保存默认值配置失败');
} finally {
setDefaultsSaving(false);
}
};
const handleExport = async () => {
if (!spuData || filteredRows.length === 0) {
message.warning('没有可导出的数据');
return;
}
setExporting(true);
try {
const exportColumns = visibleColumns.map((col) => ({
key: col.dataIndex,
label: FIELD_LABELS[col.dataIndex] || col.dataIndex,
percent: PERCENT_FIELD_KEYS.has(col.dataIndex),
}));
const exportRows = filteredRows.map((row) => {
const item = {};
exportColumns.forEach(({ key }) => { item[key] = row[key]; });
return item;
});
const blob = await exportPricingTable({ spu: spuData.spu, columns: exportColumns, rows: exportRows });
const stamp = new Date();
const pad = (n) => String(n).padStart(2, '0');
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `上架测算_${spuData.spu}_${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(error.message || '导出失败,请稍后重试');
} finally {
setExporting(false);
}
};
const handleRestore = async (record) => {
setRestoring(true);
try {
const detail = await fetchPricingHistoryDetail(record.id);
const payload = detail?.payload;
if (!payload || !Array.isArray(payload.rows)) {
throw new Error('该记录的快照数据已损坏,无法还原');
}
setSpuData({
spu: payload.spuCode || detail.spuCode,
source: null,
restoredAt: detail.createdAt,
warnings: [],
freightProfile: payload.freightProfile || null,
});
const restoredIsBlackLabelShop = typeof payload.isBlackLabelShop === 'boolean'
? payload.isBlackLabelShop
: inferBlackLabelShop(payload.rows);
setIsBlackLabelShop(restoredIsBlackLabelShop);
setRows(payload.rows.map((row) => ({
...row,
// 兼容早期快照:只有 groupPrice(拼单价)时还原为到手价
price: row.price ?? row.groupPrice ?? null,
id: row.id || createRowId(),
// 旧快照没有拼多多字段时补默认值
coupon: valueOrDefaultWhenMissing(row, 'coupon', defaults?.coupon ?? 20),
activityDiscount: valueOrDefaultWhenMissing(row, 'activityDiscount', defaults?.activityDiscount ?? 0.6),
commission: valueOrDefaultWhenMissing(
row,
'commission',
getPlatformCommissionRate(restoredIsBlackLabelShop)
),
})));
setSelectionContext(null);
setSpuInput(payload.spuCode || detail.spuCode || '');
setFilters(DEFAULT_FILTERS);
setPagination((prev) => ({ ...prev, current: 1 }));
setHistoryOpen(false);
message.success(`已还原 ${detail.spuCode} 的测算记录`);
} catch (error) {
message.error(error.message || '还原测算记录失败');
} finally {
setRestoring(false);
}
};
const resetFilters = () => {
setFilters(DEFAULT_FILTERS);
setPagination((prev) => ({ ...prev, current: 1 }));
};
const applyFilter = (patch) => {
setFilters((prev) => ({ ...prev, ...patch }));
setPagination((prev) => ({ ...prev, current: 1 }));
};
const numericSorter = (field) => (a, b) => {
const left = Number.isFinite(a[field]) ? a[field] : Number.NEGATIVE_INFINITY;
const right = Number.isFinite(b[field]) ? b[field] : Number.NEGATIVE_INFINITY;
return left - right;
};
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: 'barcode',
width: 184,
fixed: 'left',
render: (value, record) => (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, minWidth: 0, whiteSpace: 'nowrap' }}>
<Popconfirm
title={`移除条码 ${value}?`}
description={`移除后不参与测算、保存${selectionContext ? '和自动上架' : ''},重新测算可恢复。`}
okText="移除"
cancelText="取消"
okButtonProps={{ danger: true }}
onConfirm={() => removeRow(record)}
>
<Tooltip title="移除此条码">
<Button
type="text"
danger
size="small"
style={{ width: 24, minWidth: 24, height: 24, padding: 0 }}
icon={<DeleteOutlined />}
aria-label={`移除条码 ${value}`}
/>
</Tooltip>
</Popconfirm>
<Popover
trigger="hover"
placement="right"
content={(
<div className="pricing-calc-sku-pop">
<div>{record.name || record.barcode}</div>
{record.supplier ? <div>供应商:{record.supplier}</div> : null}
{record.category ? <div>类目:{record.category}</div> : null}
</div>
)}
>
<span className="pricing-calc-barcode">{value}</span>
</Popover>
</span>
),
},
{
title: <AutoHeaderTitle tip="聚水潭条码状态,包含销售、退市和停产">条码状态</AutoHeaderTitle>,
dataIndex: 'skuStatus',
width: 92,
align: 'center',
onHeaderCell: () => autoHeader,
render: (value) => <SkuStatusTag status={value} />,
},
{
title: <AutoHeaderTitle tip="聚水潭实时库存;悬停可查看仓库明细">库存</AutoHeaderTitle>,
dataIndex: 'stock',
width: 88,
align: 'right',
sorter: numericSorter('stock'),
onHeaderCell: () => autoHeader,
render: (value, record) => <StockCell value={value} warehouses={record.stockWarehouses} />,
},
{
title: '尺寸',
dataIndex: 'size',
width: 110,
render: (value) => <span className="pricing-calc-text">{value || '-'}</span>,
},
{
title: '颜色',
dataIndex: 'color',
width: 100,
render: (value) => <span className="pricing-calc-text">{value || '-'}</span>,
},
{
title: <AutoHeaderTitle tip="按条码前缀自动判断:GBGJ- 顾家 / GBSFY- 索菲亚 / 其余简尚">品牌名称</AutoHeaderTitle>,
dataIndex: 'brand',
width: 96,
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-text">{value || '-'}</span>,
},
{
title: <AutoHeaderTitle tip="来自商品数据,自动获取">成本</AutoHeaderTitle>,
dataIndex: 'cost',
width: 96,
align: 'right',
sorter: numericSorter('cost'),
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
{
title: <AutoHeaderTitle tip="公司最低限价,自动获取">公司限价</AutoHeaderTitle>,
dataIndex: 'companyPriceLimit',
width: 96,
align: 'right',
sorter: numericSorter('companyPriceLimit'),
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatMoney(value)}</span>,
},
{
title: <AutoHeaderTitle tip="默认取公司限价;无限价时取成本价的 2 倍,可填写;优惠后实际到手价,利润按此价计算">到手价</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="默认 30,可填写">安装费</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="接口全国平均运费(qgyf),自动获取">运费</AutoHeaderTitle>,
dataIndex: 'shipping',
width: 96,
align: 'right',
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="黑标店铺 6% / 非黑标 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.5%">种菜占比</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="按条码前缀自动判断:GBGJ- 7% / GBSFY- 6% / 其余 0">品牌使用费</AutoHeaderTitle>,
dataIndex: 'brandFee',
width: 100,
align: 'right',
onHeaderCell: () => autoHeader,
render: (value) => <span className="pricing-calc-value">{formatPercent(value)}</span>,
},
{
title: <AutoHeaderTitle tip="目标营业利润率,默认 15%,可填写">营业利润率</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: record.profitAmount !== null && 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',
fixed: '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',
fixed: '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="公式:拼单价向上取整;保存定价表时作为自动上架 SKU 最终定价">最终拼单价</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 visibleColumns = (() => {
const byKey = new Map(columns.map((col) => [col.dataIndex, { ...col, key: col.dataIndex }]));
return fieldConfig.order
.filter((key) => !fieldConfig.hidden.includes(key) && byKey.has(key))
.map((key) => byKey.get(key));
})();
const tableScrollX = visibleColumns.reduce((sum, col) => sum + (col.width || 100), 0) + 40;
const freightProfile = spuData?.freightProfile;
const renderHero = () => (
<div className="pricing-calc-hero">
<div className="pricing-calc-hero__icon">
<CalculatorOutlined />
</div>
<h2 className="pricing-calc-hero__title">公司品拼多多测算</h2>
<p className="pricing-calc-hero__subtitle">
输入公司 SPU,自动带出全部条码(含销售、退市、停产)的状态、库存、成本、限价与图片,设置到手价与优惠折扣后实时倒推毛利率、推广空间、营业利润与拼单价。
</p>
<Input.Search
className="pricing-calc-hero__search"
size="large"
placeholder="输入公司 SPU,如 SG0088"
enterButton="开始测算"
loading={searching}
value={spuInput}
disabled={isSelectionPricingMode}
onChange={(event) => setSpuInput(event.target.value)}
onSearch={runSearch}
/>
<div className="pricing-calc-black-label-toggle pricing-calc-hero__black-label">
<Checkbox
checked={isBlackLabelShop}
onChange={(event) => handleBlackLabelShopChange(event.target.checked)}
>
黑标店铺
</Checkbox>
<span>平台佣金 {formatPercent(getPlatformCommissionRate(isBlackLabelShop))}</span>
</div>
{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">条码状态、库存、成本、公司限价自动获取;营业利润额为负将红色警示(亏本)</span>
{!isSelectionPricingMode ? (
<Button type="link" icon={<HistoryOutlined />} onClick={() => setHistoryOpen(true)}>
测算历史
</Button>
) : null}
</div>
</div>
);
const renderEmbeddedLoading = () => (
<div className="pricing-calc-hero is-embedded-state">
<span className="pricing-calc-hero__icon"><CalculatorOutlined /></span>
<h2 className="pricing-calc-hero__title">正在打开定价明细</h2>
<p className="pricing-calc-hero__subtitle">系统正在读取该自动上架任务已保存的 SKU 定价表。</p>
</div>
);
const renderEmbeddedError = () => (
<div className="pricing-calc-hero is-embedded-state">
<Alert
className="pricing-calc-hero__error"
type="error"
showIcon
message="定价明细读取失败"
description={searchError}
/>
<Button
type="primary"
icon={<ReloadOutlined />}
loading={searching}
onClick={() => runSearch(selectionSpu)}
>
重新读取定价明细
</Button>
</div>
);
const renderResult = () => (
<div
style={embedded ? {
display: 'flex',
minHeight: '100%',
flexDirection: 'column',
} : undefined}
>
<div className="pricing-calc-spubar">
<div className="pricing-calc-spubar__content">
<div className="pricing-calc-spubar__controls">
<Input.Search
className="pricing-calc-spubar__search"
placeholder="输入公司 SPU"
enterButton="测算"
loading={searching}
value={spuInput}
disabled={isSelectionPricingMode}
onChange={(event) => setSpuInput(event.target.value)}
onSearch={runSearch}
/>
<div className="pricing-calc-black-label-toggle pricing-calc-spubar__black-label">
<Checkbox
checked={isBlackLabelShop}
onChange={(event) => handleBlackLabelShopChange(event.target.checked)}
>
黑标店铺
</Checkbox>
<span>平台佣金 {formatPercent(getPlatformCommissionRate(isBlackLabelShop))}</span>
</div>
</div>
<div className="pricing-calc-spubar__meta">
<Tag color="blue" className="pricing-calc-spubar__spu">{spuData.spu}</Tag>
<span>{rows.length} 个条码</span>
{spuData.source ? <span>来源:{formatSourceLabel(spuData.source)}</span> : null}
{spuData.restoredAt ? <span>历史还原</span> : null}
{selectionContext ? <Tag color="processing">自动上架任务:{selectionContext.taskCode}</Tag> : null}
{selectionContext?.listedItemId ? <Tag color="success">商品ID:{selectionContext.listedItemId}</Tag> : null}
{selectionContext?.pricingTableSavedAt ? <span>定价表已保存 v{selectionContext.pricingTableVersion}</span> : null}
{isAlibaba1688Selection ? (
<Tag color={productCodeCompleted ? 'success' : selectionContext?.productCodeStatus === 'unknown' ? 'warning' : 'processing'}>
公司SKU编码:{productCodeCompleted
? `已生成 ${selectionContext.productCodeStyleCode || ''}`
: selectionContext?.productCodeStatus === 'unknown' ? '待人工核对' : '待生成'}
</Tag>
) : null}
<span>
全国运费:{freightProfile?.nationalFreight != null ? `¥${formatMoney(freightProfile.nationalFreight)}` : '未获取,请手填'}
</span>
<span>售后费用:固定 5%</span>
</div>
<div className="pricing-calc-spubar__actions">
{!isSelectionPricingMode ? <Button icon={<ReloadOutlined />} onClick={resetToHero}>新的测算</Button> : null}
{!isSelectionPricingMode ? (
<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 />} disabled={rows.length === 0}>批量折扣</Button>
</Popover>
<Button icon={<ControlOutlined />} onClick={() => setDefaultsOpen(true)}>默认值</Button>
<Button icon={<DownloadOutlined />} loading={exporting} onClick={handleExport}>导出</Button>
<Button icon={<SettingOutlined />} onClick={() => setFieldConfigOpen(true)}>字段配置</Button>
{selectionContext && !embedded ? (
<Button onClick={() => navigate('/planning/dev-style-preview')}>返回仪表盘</Button>
) : null}
<Button
type="primary"
icon={<SaveOutlined />}
disabled={rows.length === 0 || Boolean(selectionContext?.listedItemId)}
onClick={openSaveModal}
>
{selectionContext?.listedItemId ? '已绑定上架商品' : selectionContext ? '保存定价表' : '保存测算记录'}
</Button>
</div>
</div>
<div className="pricing-calc-summary" aria-label="定价测算摘要">
{summaryMetrics.map((item) => (
<SycmDashboardMetric key={item.key} label={item.label} value={item.value} help={false} />
))}
</div>
</div>
{spuData.warnings?.length ? (
<Alert
className="pricing-calc-warning"
type="warning"
showIcon
message="部分数据未能自动获取,已在表格中留空,可手动填写"
description={spuData.warnings.join(';')}
/>
) : null}
<div className="pricing-calc-filter">
<span className="pricing-calc-filter__item">
<label>条码状态</label>
<Select
allowClear
style={{ width: 104 }}
placeholder="全部"
options={statusOptions}
value={filters.status}
onChange={(next) => applyFilter({ status: next })}
/>
</span>
<span className="pricing-calc-filter__item">
<label>颜色</label>
<Select
allowClear
showSearch
style={{ width: 120 }}
placeholder="全部"
options={colorOptions}
value={filters.color}
onChange={(next) => applyFilter({ color: next })}
/>
</span>
<span className="pricing-calc-filter__item">
<label>尺寸</label>
<Select
allowClear
showSearch
style={{ width: 140 }}
placeholder="全部"
options={sizeOptions}
value={filters.size}
onChange={(next) => applyFilter({ size: next })}
/>
</span>
<Input
allowClear
className="pricing-calc-filter__keyword"
prefix={<SearchOutlined />}
placeholder="条码 / 名称 / 尺寸 / 颜色"
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>
<div
ref={tableRegionRef}
style={{
minWidth: 0,
...(embedded ? { minHeight: 0, flex: '1 1 auto', overflow: 'hidden' } : {}),
}}
>
<Table
className="pricing-calc-table"
rowKey="id"
size="small"
columns={visibleColumns}
dataSource={filteredRows}
scroll={{
x: tableScrollX,
y: embedded ? embeddedTableScrollY : undefined,
scrollToFirstRowOnChange: true,
}}
onChange={(nextPagination) => setPagination({
current: nextPagination.current,
pageSize: nextPagination.pageSize,
})}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
showSizeChanger: true,
pageSizeOptions: ['20', '50', '100'],
showTotal: (total) => `共 ${total} 行`,
}}
locale={{
emptyText: <Empty description="没有匹配的条码,请调整筛选条件" />,
}}
/>
</div>
</div>
);
const renderCompanyCalculator = () => (
spuData
? renderResult()
: embedded && isSelectionPricingMode
? (searchError ? renderEmbeddedError() : renderEmbeddedLoading())
: renderHero()
);
return (
<SycmPage
className={`pricing-calc-page${embedded ? ' is-embedded' : ''}`}
style={embedded ? { height: '100%', minHeight: 0 } : undefined}
>
<SycmBusinessPanel
className="pricing-calc-panel"
aria-label="上架测算内容区"
style={embedded ? { height: '100%', minHeight: 0 } : undefined}
>
{embedded ? renderCompanyCalculator() : (
<Tabs
className="pricing-calc-mode-tabs"
activeKey={calculatorMode}
destroyOnHidden={false}
onChange={setCalculatorMode}
items={[
{
key: 'company',
label: <span className="pricing-calc-mode-tabs__label"><HomeOutlined />公司品拼多多测算</span>,
children: renderCompanyCalculator(),
},
{
key: 'dropship',
label: <span className="pricing-calc-mode-tabs__label"><ShopOutlined />1688代发拼多多测算</span>,
children: <DropshipCalculator />,
},
]}
/>
)}
<Modal
title={isAlibaba1688Selection ? '保存1688定价并申请公司SKU编码' : selectionContext ? '保存自动上架定价表' : '保存测算记录'}
open={saveModalOpen}
width={isAlibaba1688Selection ? 920 : 520}
okText={isAlibaba1688Selection && !productCodeCompleted ? '保存并生成编码' : '保存'}
cancelText="取消"
confirmLoading={saving}
onOk={handleSave}
onCancel={() => setSaveModalOpen(false)}
>
<p className="pricing-calc-save__hint">
{selectionContext
? `将保存 SPU「${spuData?.spu}」共 ${rows.length} 行定价,表中的拼单价会作为自动上架SKU最终售价。`
: `将保存 SPU「${spuData?.spu}」共 ${rows.length} 行的完整测算快照(输入、费率与公式结果),仅自己可见。`}
</p>
{isAlibaba1688Selection ? (
<>
<Alert
className="pricing-calc-product-code__warning"
type={productCodeCompleted ? 'success' : 'warning'}
showIcon
message={productCodeCompleted
? `公司SKU编码已生成,款式编码:${selectionContext?.productCodeStyleCode || '--'}`
: '本操作会正式生成公司SKU编码,SKU顺序决定编码后缀,提交后不可随意改名或调序。'}
/>
<Form
form={productCodeForm}
layout="vertical"
className="pricing-calc-product-code__form"
disabled={productCodeCompleted || productCodeMetaLoading}
>
<Form.Item name="operationCenter" label="运营中心" rules={[{ required: true, message: '请选择运营中心' }]}>
<Select options={(productCodeMeta?.operationCenters || []).map((value) => ({ value, label: value }))} />
</Form.Item>
<Form.Item name="categoryName" label="商品分类" rules={[{ required: true, message: '请选择商品分类' }]}>
<Select showSearch optionFilterProp="label" options={(productCodeMeta?.categories || []).map((value) => ({ value, label: value }))} />
</Form.Item>
{PRODUCT_CODE_CONFIG_FIELDS.map(({ key, label }) => (
<Form.Item key={key} name={key} label={label} rules={[{ required: true, message: `请选择${label}` }]}>
<Select options={(productCodeMeta?.tagOptions?.[key] || []).map((value) => ({ value, label: value }))} />
</Form.Item>
))}
</Form>
<div className="pricing-calc-product-code__mapping">
<div className="pricing-calc-product-code__mapping-head">
<span>SKU编码</span><span>原SKU名</span><span>最终SKU名</span><span>公司SKU编码</span>
</div>
{rows.map((row) => (
<div className="pricing-calc-product-code__mapping-row" key={row.barcode}>
<span title={row.barcode}>{row.barcode}</span>
<span title={row.originalSkuName}>{row.originalSkuName || '--'}</span>
<Input
value={row.selectedSkuName || row.name}
disabled={productCodeCompleted}
maxLength={255}
onChange={(event) => setRows((current) => current.map((item) => (
item.id === row.id
? { ...item, name: event.target.value, selectedSkuName: event.target.value }
: item
)))}
/>
<span className={row.internalProductCode ? 'is-complete' : ''}>{row.internalProductCode || '待生成'}</span>
</div>
))}
</div>
</>
) : null}
<Input
value={saveNote}
maxLength={200}
placeholder="备注(可选),如:618 调价方案"
onChange={(event) => setSaveNote(event.target.value)}
/>
</Modal>
<DefaultsConfigModal
open={defaultsOpen}
onClose={() => setDefaultsOpen(false)}
defaults={defaults}
saving={defaultsSaving}
onSave={handleSaveDefaults}
/>
<FieldConfigDrawer
open={fieldConfigOpen}
onClose={() => setFieldConfigOpen(false)}
config={fieldConfig}
onChange={(next) => {
setFieldConfig(next);
saveFieldConfig(next);
}}
/>
<HistoryDrawer
open={historyOpen}
onClose={() => setHistoryOpen(false)}
onRestore={handleRestore}
refreshKey={historyRefreshKey}
/>
</SycmBusinessPanel>
</SycmPage>
);
};
export default PricingCalculator;