Files
ozon-seller-kit/studio/src/pages/trial/TrialExportPanel.tsx
T

393 lines
16 KiB
TypeScript
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.
/**
* 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>
);
}