393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/**
|
||
* 7 入库与导出(对齐 v1 web/ozonSeller.html 上品登记表):
|
||
* 「录入当前商品」把当前试算商品登记进当日登记表(localStorage,v1 同款交互),
|
||
* 表格展示当日录入的商品(17 列,物流费/平台总抽成/重量/尺寸/状态为可选列),
|
||
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
|
||
*/
|
||
import { useCallback, useEffect, useState } from 'react';
|
||
import { Button, Input, message, Modal, Popconfirm, Table, Tooltip, Typography } from 'antd';
|
||
import type { ColumnsType } from 'antd/es/table';
|
||
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
|
||
import { ProductDetail } from '@/services/product';
|
||
import { copyText, downloadCsv } from '@/utils/file';
|
||
|
||
const { Text } = Typography;
|
||
|
||
/** v1 按钮/金额配色 */
|
||
const COLOR_SECONDARY = '#10b981'; // v1 bg-secondary(录入按钮)
|
||
|
||
const RECORDS_KEY = 'trialRegisterRecords';
|
||
|
||
interface RegisterRecord {
|
||
registeredAt: string; // 录入时间(当日过滤用)
|
||
sku: string;
|
||
modelCode: string;
|
||
productName: string;
|
||
status: string;
|
||
purchaseUrl: string;
|
||
sellingPrice: string;
|
||
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
|
||
sellingPriceRub: string;
|
||
sellingPriceRubReserved: string; // 划线价 ₽
|
||
lineDiscountRate: number; // 划线折扣率 %
|
||
exchangeRate: string;
|
||
logisticsFee: string;
|
||
receivedPrice: string;
|
||
purchasePrice: string;
|
||
profit: string;
|
||
profitRate: string;
|
||
fullCommission: string;
|
||
totalCost: string;
|
||
weight: string;
|
||
dimensions: string;
|
||
logisticsLevel: string; // 低 / 高 / Premium
|
||
}
|
||
|
||
function loadRecords(): RegisterRecord[] {
|
||
try {
|
||
const s = localStorage.getItem(RECORDS_KEY);
|
||
if (s) return JSON.parse(s) as RegisterRecord[];
|
||
} catch { /* 忽略坏数据 */ }
|
||
return [];
|
||
}
|
||
|
||
function saveRecords(records: RegisterRecord[]) {
|
||
try {
|
||
localStorage.setItem(RECORDS_KEY, JSON.stringify(records));
|
||
} catch { /* 忽略存储失败 */ }
|
||
}
|
||
|
||
/** 是否今天(本地时区) */
|
||
function isToday(iso: string): boolean {
|
||
if (!iso) return false;
|
||
const d = new Date(iso);
|
||
const now = new Date();
|
||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate();
|
||
}
|
||
|
||
const cny = (v: unknown) => {
|
||
const n = parseFloat(String(v ?? ''));
|
||
return isNaN(n) ? '--' : `¥ ${n.toFixed(2)}`;
|
||
};
|
||
const rub = (v: unknown) => {
|
||
const n = parseFloat(String(v ?? ''));
|
||
return isNaN(n) ? '--' : `₽ ${n.toFixed(2)}`;
|
||
};
|
||
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
function toRecord(product: ProductDetail): RegisterRecord | null {
|
||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||
const pr = (product.pricing ?? {}) as Record<string, any>;
|
||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||
if (!pr.calculatedAt) return null;
|
||
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
||
const dim = (v: number | null | undefined) => (v == null ? '' : String(v * scale));
|
||
const level = (pr.logisticsLevel as string) ?? 'low';
|
||
const f = (v: unknown, digits = 2) => (v == null ? '' : Number(v).toFixed(digits));
|
||
return {
|
||
registeredAt: new Date().toISOString(),
|
||
sku: product.offer_id || '',
|
||
modelCode: (raw.model_code as string) ?? '',
|
||
productName: ((raw.title_zh as string) ?? (raw.title as string) ?? product.name ?? '').trim(),
|
||
status: '在售',
|
||
purchaseUrl: (raw.purchase_url as string) ?? '',
|
||
sellingPrice: f(pr.sellingPriceCny),
|
||
sellingPriceReserved: f(pr.linePriceCny),
|
||
sellingPriceRub: f(pr.sellingPriceRub),
|
||
sellingPriceRubReserved: f(pr.linePriceRub),
|
||
lineDiscountRate: pr.lineDiscountRate ?? 50,
|
||
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
|
||
logisticsFee: f(pr.logisticsFee),
|
||
receivedPrice: f(pr.receivedPrice),
|
||
purchasePrice: f(pr.purchasePrice),
|
||
profit: f(pr.profitPrice),
|
||
profitRate: pr.profitRate != null ? String(Math.round(Number(pr.profitRate))) : '',
|
||
fullCommission: f(pr.fullCommission),
|
||
totalCost: f(pr.totalCost),
|
||
weight: product.weight != null ? String(product.weight) : '',
|
||
dimensions: product.depth != null && product.width != null && product.height != null
|
||
? `${dim(product.depth)}x${dim(product.width)}x${dim(product.height)}`
|
||
: '',
|
||
logisticsLevel: level === 'high' ? '高' : level === 'high2' ? 'Premium' : '低',
|
||
};
|
||
}
|
||
|
||
const CSV_HEADER = [
|
||
'货号(sku)', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
|
||
'净利润', '净利率', '卢布销价', '重量', '尺寸', '状态', 'Ozon地址', '采买地址',
|
||
];
|
||
|
||
function recordCsvRow(r: RegisterRecord): Array<string | number> {
|
||
return [
|
||
r.sku || '',
|
||
r.productName || '',
|
||
r.purchasePrice || '',
|
||
r.logisticsFee || '',
|
||
r.fullCommission || '',
|
||
r.receivedPrice || '',
|
||
r.totalCost || '',
|
||
r.sellingPrice || '',
|
||
r.profit || '',
|
||
r.profitRate ? `${r.profitRate}%` : '',
|
||
r.sellingPriceRub || '',
|
||
r.weight || '',
|
||
r.dimensions || '',
|
||
r.status || '在售',
|
||
r.sku ? `https://www.ozon.ru/product/${r.sku}` : '',
|
||
r.purchaseUrl || '',
|
||
];
|
||
}
|
||
|
||
/** 组合码一行:`货号 划线₽价(两位)`,无划线价回退销售价₽ */
|
||
function comboLine(r: RegisterRecord): string {
|
||
const rubPrice = r.sellingPriceRubReserved || r.sellingPriceRub;
|
||
if (!r.sku || !rubPrice) return '';
|
||
return `${r.sku} ${Number(rubPrice).toFixed(2)}`;
|
||
}
|
||
|
||
interface Props {
|
||
product: ProductDetail;
|
||
}
|
||
|
||
export default function TrialExportPanel({ product }: Props) {
|
||
const [records, setRecords] = useState<RegisterRecord[]>([]);
|
||
const [showOptional, setShowOptional] = useState(false);
|
||
const [comboOpen, setComboOpen] = useState(false);
|
||
const [comboText, setComboText] = useState('');
|
||
|
||
useEffect(() => {
|
||
setRecords(loadRecords().filter((r) => isToday(r.registeredAt)));
|
||
}, []);
|
||
|
||
const currentRecord = toRecord(product);
|
||
|
||
/** 录入当前商品(对齐 v1 recordData:货号必填、先计价、当日货号去重) */
|
||
const registerCurrent = useCallback(() => {
|
||
if (!currentRecord) {
|
||
message.warning('请先在「2 价格试算」完成计价,再录入商品');
|
||
return;
|
||
}
|
||
if (!currentRecord.sku) {
|
||
message.warning('请先填写货号(SKU),再录入商品');
|
||
return;
|
||
}
|
||
const list = loadRecords().filter((r) => isToday(r.registeredAt));
|
||
const dup = list.findIndex((r) => r.sku.trim().toLowerCase() === currentRecord.sku.trim().toLowerCase());
|
||
if (dup !== -1) {
|
||
message.warning(`货号「${currentRecord.sku}」已存在于当日登记表(第 ${dup + 1} 条),请勿重复录入`);
|
||
return;
|
||
}
|
||
const next = [currentRecord, ...list];
|
||
saveRecords(next);
|
||
setRecords(next);
|
||
message.success(`商品「${currentRecord.productName || currentRecord.sku}」录入成功`);
|
||
}, [currentRecord]);
|
||
|
||
const deleteRecord = (sku: string) => {
|
||
const next = loadRecords().filter((r) => r.sku !== sku);
|
||
saveRecords(next);
|
||
setRecords(next.filter((r) => isToday(r.registeredAt)));
|
||
};
|
||
|
||
const clearRecords = () => {
|
||
saveRecords([]);
|
||
setRecords([]);
|
||
message.success('登记表已清空');
|
||
};
|
||
|
||
const openCombo = () => {
|
||
if (records.length === 0) {
|
||
message.warning('登记表为空,请先录入商品');
|
||
return;
|
||
}
|
||
const lines = records.map(comboLine).filter(Boolean);
|
||
setComboText(lines.join('\n'));
|
||
setComboOpen(true);
|
||
};
|
||
|
||
const exportCsv = () => {
|
||
if (records.length === 0) {
|
||
message.warning('登记表为空,请先录入商品');
|
||
return;
|
||
}
|
||
const d = new Date();
|
||
const p = (v: number) => String(v).padStart(2, '0');
|
||
downloadCsv(
|
||
[CSV_HEADER, ...records.map(recordCsvRow)],
|
||
`${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}.csv`,
|
||
);
|
||
message.success(`已导出 ${records.length} 条记录`);
|
||
};
|
||
|
||
const buildColumns = (): ColumnsType<RegisterRecord> => {
|
||
const money = (field: keyof RegisterRecord, fmt: (v: unknown) => string) => ({
|
||
dataIndex: field,
|
||
render: (v: unknown) => <Text style={{ fontSize: 13 }}>{fmt(v)}</Text>,
|
||
});
|
||
const renderLink = (url: string, maxWidth: number) => (
|
||
<Tooltip title={url}>
|
||
<a
|
||
href={url.startsWith('http') ? url : `https://${url}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
style={{
|
||
display: 'inline-block',
|
||
maxWidth,
|
||
overflow: 'hidden',
|
||
textOverflow: 'ellipsis',
|
||
whiteSpace: 'nowrap',
|
||
verticalAlign: 'bottom',
|
||
fontSize: 12,
|
||
}}
|
||
>
|
||
{url}
|
||
</a>
|
||
</Tooltip>
|
||
);
|
||
const base: ColumnsType<RegisterRecord> = [
|
||
{
|
||
title: '操作',
|
||
width: 70,
|
||
render: (_, r) => (
|
||
<Popconfirm title="删除这条记录?" onConfirm={() => deleteRecord(r.sku)}>
|
||
<Button size="small" danger ghost type="primary">删除</Button>
|
||
</Popconfirm>
|
||
),
|
||
},
|
||
{ title: '货号(sku)', dataIndex: 'sku', width: 110, render: (v: string) => <Text style={{ fontSize: 13 }}>{v || '--'}</Text> },
|
||
{
|
||
title: '商品名',
|
||
dataIndex: 'productName',
|
||
ellipsis: true,
|
||
render: (v: string) => <Text style={{ fontSize: 13 }}>{v || '--'}</Text>,
|
||
},
|
||
{ title: '进货价', width: 90, ...money('purchasePrice', cny) },
|
||
];
|
||
const optional: ColumnsType<RegisterRecord> = [
|
||
{ title: '物流费', width: 95, ...money('logisticsFee', cny) },
|
||
{ title: '平台总抽成', width: 105, ...money('fullCommission', cny) },
|
||
];
|
||
const rest: ColumnsType<RegisterRecord> = [
|
||
{ title: '实收价', width: 90, ...money('receivedPrice', cny) },
|
||
{ title: '完全成本', width: 95, ...money('totalCost', cny) },
|
||
{ title: '销售价', width: 95, ...money('sellingPrice', cny) },
|
||
{ title: '净利润', width: 90, ...money('profit', cny) },
|
||
{ title: '净利率', dataIndex: 'profitRate', width: 80, render: (v: string) => <Text style={{ fontSize: 13 }}>{v ? `${v}%` : '--'}</Text> },
|
||
{ title: '卢布销价', width: 100, ...money('sellingPriceRub', rub) },
|
||
];
|
||
const optional2: ColumnsType<RegisterRecord> = [
|
||
{ title: '重量', dataIndex: 'weight', width: 85, render: (v: string) => <Text style={{ fontSize: 13 }}>{v ? `${v} g` : '--'}</Text> },
|
||
{ title: '尺寸', dataIndex: 'dimensions', width: 120, render: (v: string) => <Text style={{ fontSize: 13 }}>{v ? `${v} cm` : '--'}</Text> },
|
||
{ title: '状态', dataIndex: 'status', width: 75, render: (v: string) => <Text style={{ fontSize: 13 }}>{v || '在售'}</Text> },
|
||
];
|
||
const links: ColumnsType<RegisterRecord> = [
|
||
{
|
||
title: 'Ozon地址',
|
||
dataIndex: 'sku',
|
||
width: 160,
|
||
render: (sku: string) => {
|
||
const url = sku ? `https://www.ozon.ru/product/${sku}` : '';
|
||
return url ? renderLink(url, 128) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>;
|
||
},
|
||
},
|
||
{
|
||
title: '采买地址',
|
||
dataIndex: 'purchaseUrl',
|
||
width: 180,
|
||
render: (v: string) =>
|
||
v ? renderLink(v, 148) : <Text type="secondary" style={{ fontSize: 12 }}>--</Text>,
|
||
},
|
||
];
|
||
return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links];
|
||
};
|
||
|
||
const registerButton = (fixed = false) => (
|
||
<Button
|
||
type="primary"
|
||
icon={<PlusOutlined />}
|
||
onClick={registerCurrent}
|
||
style={
|
||
fixed
|
||
? {
|
||
position: 'fixed',
|
||
right: 24,
|
||
bottom: 24,
|
||
zIndex: 100,
|
||
background: COLOR_SECONDARY,
|
||
height: 40,
|
||
width: 124,
|
||
paddingInline: 16,
|
||
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
|
||
}
|
||
: { background: COLOR_SECONDARY }
|
||
}
|
||
>
|
||
录入商品
|
||
</Button>
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
{/* 工具条:左=录入按钮;右=可选列切换/导出/清空(按钮配色对齐 v1) */}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||
{registerButton()}
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||
<Button
|
||
type={showOptional ? 'primary' : 'default'}
|
||
ghost={showOptional}
|
||
onClick={() => setShowOptional((v) => !v)}
|
||
>
|
||
显示物流费/平台总抽成/重量/尺寸/状态
|
||
</Button>
|
||
<Button type="primary" ghost icon={<CopyOutlined />} onClick={openCombo}>
|
||
导出组合码
|
||
</Button>
|
||
<Button type="primary" ghost icon={<FileTextOutlined />} onClick={exportCsv}>
|
||
导出记录
|
||
</Button>
|
||
<Popconfirm title="确认表格已经导出!此操作将清空已有数据。" onConfirm={clearRecords}>
|
||
<Button danger ghost icon={<RestOutlined />}>清空记录</Button>
|
||
</Popconfirm>
|
||
</div>
|
||
</div>
|
||
|
||
<Table
|
||
rowKey="sku"
|
||
size="small"
|
||
columns={buildColumns()}
|
||
dataSource={records}
|
||
pagination={false}
|
||
scroll={{ x: 'max-content' }}
|
||
locale={{ emptyText: '当日暂无录入记录,点击「录入当前商品」登记' }}
|
||
/>
|
||
|
||
{/* 组合码弹窗(对齐 v1 上品组合码 textarea) */}
|
||
<Modal
|
||
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
|
||
open={comboOpen}
|
||
onCancel={() => setComboOpen(false)}
|
||
footer={[
|
||
<Button
|
||
key="copy"
|
||
type="primary"
|
||
style={{ background: COLOR_SECONDARY }}
|
||
icon={<DownloadOutlined />}
|
||
onClick={async () => {
|
||
const ok = await copyText(comboText);
|
||
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
|
||
}}
|
||
>
|
||
复制结果
|
||
</Button>,
|
||
<Button key="close" onClick={() => setComboOpen(false)}>关闭</Button>,
|
||
]}
|
||
>
|
||
<Input.TextArea rows={10} readOnly value={comboText} style={{ fontFamily: 'monospace' }} />
|
||
</Modal>
|
||
|
||
{/* 页面右下角固定录入按钮(不随页面滚动) */}
|
||
{registerButton(true)}
|
||
</div>
|
||
);
|
||
}
|