feat: 商品试算页面前端部分开发完成

This commit is contained in:
R524809
2026-08-27 16:04:27 +08:00
parent 7e1192f2ef
commit bb8f235e34
8 changed files with 702 additions and 482 deletions
+325 -162
View File
@@ -1,87 +1,148 @@
/**
* 05 入库与导出(docs/v2.1/trial-page.md §7):
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 划线₽价)。
* Phase A 由前端拼装(逐个拉详情),Phase D 可换 GET /api/export/trial-csv。
* 7 入库与导出(对齐 v1 web/ozonSeller.html 上品登记表):
* 「录入当前商品」把当前试算商品登记进当日登记表(localStorage,v1 同款交互),
* 表格展示当日录入的商品(17 列,物流费/平台总抽成/重量/尺寸/状态为可选列),
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
*/
import { useState } from 'react';
import { Button, Descriptions, Input, message, Modal, Space, Tag, Typography } from 'antd';
import { CopyOutlined, DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
import { getProduct, listProducts, ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { useCallback, useEffect, useState } from 'react';
import { Button, Input, message, Modal, Popconfirm, Table, 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';
import { STAGE_LABEL } from '../collection/CollectionPage';
const { Text } = Typography;
const CSV_HEADER = [
'货号', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
'净利润', '净利率', '卢布销价', '重量(g)', '尺寸(cm)', '状态', 'Ozon地址', '采买地址',
];
/** 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; // 划线价 ₽
lineMultiplier: number; // 划线价倍数 %(v1 discountReserve 位)
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 buildRow(p: ProductDetail): Array<string | number | null> {
function toRecord(product: ProductDetail): RegisterRecord | null {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pr = (p.pricing ?? {}) as Record<string, any>;
const raw = (p.raw ?? {}) as Record<string, unknown>;
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? p.name ?? '').trim();
const scale = p.dimension_unit === 'cm' ? 1 : 0.1;
const dims =
p.depth != null && p.width != null && p.height != null
? `${p.depth * scale}×${p.width * scale}×${p.height * scale}`
: '';
const n = (v: unknown, digits = 2) => (v == null || v === '' ? '' : Number(v).toFixed(digits));
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),
lineMultiplier: pr.lineMultiplier ?? 100,
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 [
p.offer_id || '',
titleZh,
n(pr.purchasePrice),
n(pr.logisticsFee),
n(pr.fullCommission),
n(pr.receivedPrice),
n(pr.totalCost),
n(pr.sellingPriceCny),
n(pr.profitPrice),
pr.profitRate != null ? pr.profitRate : '',
n(pr.sellingPriceRub, 0),
p.weight ?? '',
dims,
STAGE_LABEL[p.stage] ?? p.stage,
p.offer_id ? `https://www.ozon.ru/product/${p.offer_id}` : '',
(raw.purchase_url as string) ?? '',
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 || '',
];
}
/** 组合码一行:`货号 划线₽价(两位)`,无划线价回退销售价₽ */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function comboCode(p: ProductDetail): string {
const pr = (p.pricing ?? {}) as Record<string, any>;
const rub = pr.linePriceRub ?? pr.reservedPriceRub ?? pr.sellingPriceRub;
if (!p.offer_id || rub == null) return '';
return `${p.offer_id} ${Number(rub).toFixed(2)}`;
}
/** 拉取全部「已计价」商品详情(分页列表 → 逐个详情;自用规模够用) */
async function fetchAllPricedProducts(): Promise<ProductDetail[]> {
const pageSize = 100;
const details: ProductDetail[] = [];
for (let page = 1; page < 50; page++) {
const res = await listProducts({ page, page_size: pageSize });
for (const item of res.items) {
try {
const d = await getProduct(item.id);
if ((d.pricing as Record<string, unknown> | null)?.calculatedAt) details.push(d);
} catch {
/* 单个失败跳过,不阻断批量导出 */
}
}
if (page * pageSize >= res.total) break;
}
return details;
}
function stamp(): string {
const d = new Date();
const p = (v: number) => String(v).padStart(2, '0');
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
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 {
@@ -89,109 +150,208 @@ interface Props {
}
export default function TrialExportPanel({ product }: Props) {
const [batchLoading, setBatchLoading] = useState(false);
const [records, setRecords] = useState<RegisterRecord[]>([]);
const [showOptional, setShowOptional] = useState(false);
const [comboOpen, setComboOpen] = useState(false);
const [comboText, setComboText] = useState('');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pr = (product.pricing ?? {}) as Record<string, any>;
useEffect(() => {
setRecords(loadRecords().filter((r) => isToday(r.registeredAt)));
}, []);
const exportCurrent = () => {
if (!pr.calculatedAt) {
message.warning('尚未计价,请先在「价格试算」完成参数填写');
const currentRecord = toRecord(product);
/** 录入当前商品(对齐 v1 recordData:货号必填、先计价、当日货号去重) */
const registerCurrent = useCallback(() => {
if (!currentRecord) {
message.warning('请先在「2 价格试算」完成计价,再录入商品');
return;
}
downloadCsv([CSV_HEADER, buildRow(product)], `试算_${product.offer_id || '商品'}_${stamp()}.csv`);
message.success('已导出本商品 CSV');
};
const exportBatch = async () => {
setBatchLoading(true);
const hide = message.loading('正在汇总已计价商品…', 0);
try {
const list = await fetchAllPricedProducts();
if (list.length === 0) {
message.warning('没有已计价的商品');
return;
}
downloadCsv([CSV_HEADER, ...list.map(buildRow)], `试算导出_${list.length}条_${stamp()}.csv`);
message.success(`已导出 ${list.length} 条`);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
hide();
setBatchLoading(false);
}
};
const copyCurrentCombo = async () => {
const code = comboCode(product);
if (!code) {
message.warning('缺少货号或计价结果(卢布划线价)');
if (!currentRecord.sku) {
message.warning('请先填写货号(SKU),再录入商品');
return;
}
const ok = await copyText(code);
ok ? message.success(`已复制:${code}`) : message.error('复制失败,请手动复制');
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 openBatchCombo = async () => {
setBatchLoading(true);
const hide = message.loading('正在汇总组合码…', 0);
try {
const list = await fetchAllPricedProducts();
const lines = list.map(comboCode).filter(Boolean);
if (lines.length === 0) {
message.warning('没有可用的组合码(需要货号 + 卢布划线价)');
return;
}
setComboText(lines.join('\n'));
setComboOpen(true);
} catch (e) {
message.error(apiErrorMessage(e));
} finally {
hide();
setBatchLoading(false);
}
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 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) =>
sku ? (
<a href={`https://www.ozon.ru/product/${sku}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }}>
{`https://www.ozon.ru/product/${sku}`}
</a>
) : (
<Text type="secondary" style={{ fontSize: 12 }}>--</Text>
),
},
{
title: '采买地址',
dataIndex: 'purchaseUrl',
width: 180,
ellipsis: true,
render: (v: string) =>
v ? (
<a href={v.startsWith('http') ? v : `https://${v}`} target="_blank" rel="noreferrer" style={{ fontSize: 12 }} title={v}>
{v}
</a>
) : (
<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,
paddingInline: 16,
boxShadow: '0 4px 12px rgba(16,185,129,0.4)',
}
: { background: COLOR_SECONDARY }
}
>
录入商品
</Button>
);
return (
<div>
<Descriptions size="small" column={3} style={{ marginBottom: 16 }}>
<Descriptions.Item label="计价入库">
{pr.calculatedAt ? (
<Tag color="green">已入库 {new Date(pr.calculatedAt as string).toLocaleString()}</Tag>
) : (
<Tag color="orange">未计价</Tag>
)}
</Descriptions.Item>
<Descriptions.Item label="货号">{product.offer_id || '—'}</Descriptions.Item>
<Descriptions.Item label="俄文标题">
<Text ellipsis={{ tooltip: product.name }} style={{ maxWidth: 220 }}>
{product.name || '—'}
</Text>
</Descriptions.Item>
</Descriptions>
{/* 工具条:左=录入按钮;右=可选列切换/导出/清空(按钮配色对齐 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>
<Space wrap>
<Button type="primary" icon={<DownloadOutlined />} onClick={exportCurrent}>
导出本商品 CSV
</Button>
<Button icon={<FileTextOutlined />} loading={batchLoading} onClick={exportBatch}>
批量导出 CSV(全部已计价)
</Button>
<Button icon={<CopyOutlined />} onClick={copyCurrentCombo}>
复制本商品组合码
</Button>
<Button loading={batchLoading} onClick={openBatchCombo}>
批量组合码
</Button>
</Space>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
组合码格式:`货号 划线₽价`(无划线价回退销售价₽),用于 Ozon 后台批量改价;
CSV 为 16 列(对齐 v1 工具台),UTF-8 BOM 编码可直接用 Excel 打开。
</Text>
<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}
@@ -200,20 +360,23 @@ export default function TrialExportPanel({ product }: Props) {
<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>,
<Button key="close" onClick={() => setComboOpen(false)}>关闭</Button>,
]}
>
<Input.TextArea rows={12} readOnly value={comboText} />
<Input.TextArea rows={10} readOnly value={comboText} style={{ fontFamily: 'monospace' }} />
</Modal>
{/* 页面右下角固定录入按钮(不随页面滚动) */}
{registerButton(true)}
</div>
);
}