feat: 商品试算页面前端部分开发完成
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user