forked from lunanting/cangku
1330 lines
51 KiB
JavaScript
1330 lines
51 KiB
JavaScript
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;
|