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
+8 -8
View File
@@ -24,11 +24,11 @@
┌──────────────────────────────────────────────────────┬────────────┐
│ 标题(中文/俄文) · stage 标签 · 保存状态 · 来源链接 · 去编辑页 │ 区域导航 │
├──────────────────────────────────────────────────────┤ (锚点滚动) │
│ 01 商品信息 标题/采集价/品牌/销量/店铺/重量/尺寸/货号/ │ │
采买地址/参数表/描述(可编辑,onBlur 落库) │ │
│ 02 价格试算 进货价/净利率/贴单费/物流等级/预留折扣/汇率 → │ │
│ 结果卡(物流费→实收价/净利润→完全成本/总抽成→ │ │
│ 销售价¥/₽/预留价) + 三类告警 │ │
│ 01 商品信息 标题/采集价/品牌/销量/店铺/货号/采买地址/ │ │
│ 参数表/描述(可编辑,onBlur 落库) │ │
│ 02 价格试算 重量/尺寸/进货价/净利率/贴单费/物流等级/划线价 │ │
倍数/汇率 → 结果卡(物流费→实收价/净利润→完全 │ │
成本/总抽成→销售价¥/₽/划线价) + 告警 │ │
│ 03 俄文文案 商品资料 → 模型选择 → 生成标题/简介/标签 → 回填 │ │
│ 04 图片与AI生图 采集图片(分组勾选/上传/AI生图) + 出图方案(规划/ │ │
│ 风格/要求/模型/一键生成+进度) + 生成结果(导出) │ → image-suite.md │
@@ -45,8 +45,8 @@
| 标题(中文) | `raw.title_zh`(回退展示 `raw.title` | 采集原标题写入 `raw.title`,人工整理后的中文名写 `title_zh` |
| 俄文标题 | `name` | 文案生成回填,也可手改 |
| 采集价 / 品牌 / 销量 / 店铺 | `raw.price` / `raw.brand` / `raw.sales` / `raw.shop` | 只读展示 |
| 重量 g | `weight``weight_unit='g'` | 采集已自动 kg→g试算计价直接用 |
| 长×宽×高 cm | `depth/width/height``dimension_unit='mm'`,cm×10 落库) | 计价与尺寸校验用 |
| 重量 g | `weight``weight_unit='g'` | 输入在 **02 价格试算左列顶部**(计价强相关);采集已自动 kg→g变化即重算落库 |
| 长×宽×高 cm | `depth/width/height``dimension_unit='mm'`cm×10 落库) | 同上,计价与尺寸校验用 |
| 货号(SKU | `offer_id` | 对齐 v1「型号-后缀」,v2.1 用单字段;CSV/组合码的主键 |
| 采买地址 | `raw.purchase_url` | 1688/淘宝采集时默认取 `source_url`;可改;导出 CSV 列 |
| 参数表 | `raw.params``[{key,value}]`) | 只读折叠展示;供文案/生图上下文 |
@@ -56,7 +56,7 @@
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认1000-500)、
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸取自 01 区块
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
+1
View File
@@ -55,6 +55,7 @@ SYSTEM_PROMPT = """你是一名熟悉俄罗斯消费者表达习惯、Ozon 商
【标签】
1. 输出10~15个标签;每个俄文标签必须是一个独立单词,不是短语,不带 #,不含标点,不把两个词用空格连接。
2. 标签中绝对禁止出现任何特殊符号:禁止 - 连字符/中划线、_ 下划线、/ 斜杠、. 句点、, 逗号等一切非字母字符(俄文/英文字母之外的字符一律不允许);需要组合概念时用去掉连字符的合成词写法(例如"новогоднийподарок"式复合词或改用两个独立标签)。
2. 标签优先覆盖品类、造型、材质、功能、风格、摆放场景等高相关搜索概念,避免同词不同变格反复出现。
3. 中文标签也尽量为一个词;tags_ru 与 tags_zh 必须逐项语义对应。
+60 -24
View File
@@ -1,18 +1,25 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Card, Col, Input, message, Row, Select, Space, Tag, Typography } from 'antd';
import { Button, Card, Col, Input, message, Row, Select, Tag, Typography } from 'antd';
import { generateCopy, getAiModels, AiModelOption, CopyResponse } from '@/services/ai';
import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { copyText } from '@/utils/file';
const { Text, Paragraph } = Typography;
/** 标签轮换配色(antd 预设色) */
const TAG_PRESETS = ['magenta', 'red', 'volcano', 'orange', 'gold', 'lime', 'green', 'cyan', 'blue', 'geekblue', 'purple'];
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => Promise<void> | void;
/** 左右分栏 spanantd 24 栅格),默认 10/14(编辑页);试算页传 11/13 */
leftSpan?: number;
rightSpan?: number;
}
/** 左侧采集信息 + 模型/生成按钮,右侧推荐标题/简介/标签 */
export default function CopyPanel({ product, onSave }: Props) {
export default function CopyPanel({ product, onSave, leftSpan = 10, rightSpan = 14 }: Props) {
const [models, setModels] = useState<AiModelOption[]>([]);
const [model, setModel] = useState<string>('');
const [sourceText, setSourceText] = useState('');
@@ -66,25 +73,34 @@ export default function CopyPanel({ product, onSave }: Props) {
}
};
/** 回填标题:同时写入俄文 name 和中文 raw.title_zh */
/** 回填标题:同时写入俄文 name 和中文 raw.title_zh,并复制俄文标题到剪贴板 */
const applyTitle = async (titleRu: string, titleZh?: string) => {
const patch: Partial<ProductDetail> = { name: titleRu };
if (titleZh) {
patch.raw = { ...raw, title_zh: titleZh };
}
await onSave(patch);
message.success('标题已回填(中俄双语)');
const ok = await copyText(titleRu);
message.success(ok ? '已回填标题并复制俄文' : '已回填标题(复制失败,请手动复制)');
};
/** 回填俄文简介并复制到剪贴板 */
const applyDesc = async () => {
if (!result) return;
await onSave({ description: result.description_ru });
message.success('已回填俄文简介');
const ok = await copyText(result.description_ru);
message.success(ok ? '已回填俄文简介并复制' : '已回填俄文简介(复制失败,请手动复制)');
};
/** 点击标签:复制俄文标签 */
const copyTag = async (tagRu: string) => {
const ok = await copyText(tagRu);
ok ? message.success(`已复制:${tagRu}`) : message.error('复制失败,请手动复制');
};
return (
<Row gutter={16}>
<Col span={10}>
<Col span={leftSpan}>
<Card size="small" title="采集信息(AI 输入)">
{/* 显示中文原标题作为参考 */}
{((raw.title_zh as string) || (raw.title as string)) && (
@@ -96,7 +112,7 @@ export default function CopyPanel({ product, onSave }: Props) {
</div>
)}
<Input.TextArea
rows={12}
rows={16}
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="采集的商品资料(可编辑后生成)"
@@ -114,7 +130,7 @@ export default function CopyPanel({ product, onSave }: Props) {
</div>
</Card>
</Col>
<Col span={14}>
<Col span={rightSpan}>
<Card size="small" title="生成结果">
{!result ? (
<div style={{ color: '#999' }}></div>
@@ -142,24 +158,36 @@ export default function CopyPanel({ product, onSave }: Props) {
ghost
onClick={() => applyTitle(t, result.titles_zh?.[i])}
>
</Button>
</div>
</div>
))}
</div>
<Space style={{ marginTop: 4 }}>
<Text strong></Text>
<Button size="small" onClick={applyDesc}>
</Button>
</Space>
<Paragraph
style={{ whiteSpace: 'pre-wrap', marginTop: 4, marginBottom: 4, fontSize: 13 }}
<div
style={{
marginTop: 4,
marginBottom: 4,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
// 与推荐标题灰底卡片(padding: 8)内的按钮右缘对齐
paddingRight: 8,
}}
>
{result.description_ru}
</Paragraph>
<Text strong></Text>
<Button size="small" type="primary" ghost onClick={applyDesc}>
</Button>
</div>
{/* 文本域展示(无滚动条自动撑高):复制时保留完整换行样式 */}
<Input.TextArea
autoSize={{ minRows: 8 }}
style={{ marginTop: 4, marginBottom: 4, fontSize: 13, overflow: 'hidden', resize: 'none' }}
value={result.description_ru}
readOnly
/>
<Paragraph
type="secondary"
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginBottom: 8 }}
@@ -168,14 +196,22 @@ export default function CopyPanel({ product, onSave }: Props) {
</Paragraph>
<Text strong></Text>
<div style={{ marginTop: 4, marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 11, marginLeft: 8 }}>
</Text>
<div style={{ marginTop: 8, marginBottom: 4, display: 'flex', flexWrap: 'wrap', rowGap: 12, columnGap: 8 }}>
{result.tags_ru.map((t, i) => (
<Tag key={i}>{t}</Tag>
<Tag
key={i}
color={TAG_PRESETS[i % TAG_PRESETS.length]}
style={{ cursor: 'pointer', marginInlineEnd: 0 }}
onClick={() => copyTag(t)}
>
{t}
{result.tags_zh?.[i] ? <span> {result.tags_zh[i]}</span> : null}
</Tag>
))}
</div>
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
{result.tags_zh?.join('、')}
</Text>
</div>
)}
</Card>
+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>
);
}
+123 -181
View File
@@ -1,5 +1,5 @@
import { useState } from 'react';
import { Col, Collapse, Input, InputNumber, message, Row, Typography } from 'antd';
import { Col, Input, message, Row, Typography } from 'antd';
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
import { ProductDetail } from '@/services/product';
import { copyText } from '@/utils/file';
@@ -7,228 +7,170 @@ import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
const { Text } = Typography;
/** 采集平台 → 是否自动填入采买地址(仅国内采购站;Ozon 等海外平台不填) */
const PURCHASE_PLATFORMS = new Set(['1688', 'taobao', 'tmall', 'pdd']);
const isPurchasePlatform = (platform: string | null | undefined) =>
!!platform && PURCHASE_PLATFORMS.has(platform.toLowerCase());
interface Props {
product: ProductDetail;
onSave: (p: Partial<ProductDetail>) => void;
}
/** 包装尺寸 mm → cm 展示 */
function dimsCm(product: ProductDetail): { l: number | null; w: number | null; h: number | null } {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
const cv = (v: number | null) => (v == null ? null : Number((v * scale).toFixed(2)));
return { l: cv(product.depth ?? null), w: cv(product.width ?? null), h: cv(product.height ?? null) };
}
/**
* 01 商品信息:采集结果核对/补录。编辑失焦即落库(父级 PATCH)。
* 重量/尺寸是计价与生图上下文的唯一真源(写 product 包装字段)
* 左右两列:左=中文标题/采集价/型号/货号/采买地址,右=俄文标题/商品描述/规格参数
* 重量/尺寸在「02 价格试算」左列维护;采买地址仅国内采购平台自动填入。
*/
export default function TrialInfoPanel({ product, onSave }: Props) {
const raw = (product.raw ?? {}) as Record<string, unknown>;
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
const [nameRu, setNameRu] = useState(product.name ?? '');
const [modelCode, setModelCode] = useState((raw.model_code as string) ?? '');
const [offerId, setOfferId] = useState(product.offer_id ?? '');
// 采买地址:仅 1688/拼多多/淘宝/天猫 来源时用 source_url 初始化
const [purchaseUrl, setPurchaseUrl] = useState(
(raw.purchase_url as string) ??
(['1688', 'taobao'].includes(product.source_platform ?? '') ? (product.source_url ?? '') : ''),
(raw.purchase_url as string) ?? (isPurchasePlatform(product.source_platform) ? (product.source_url ?? '') : ''),
);
const [desc, setDesc] = useState((raw.desc as string) ?? '');
const [weightG, setWeightG] = useState<number | null>(product.weight ?? null);
const [dims, setDims] = useState(dimsCm(product));
const patchRaw = (patch: Record<string, unknown>) => onSave({ raw: { ...raw, ...patch } });
const readOnly = (v: unknown) => (v ? String(v) : '—');
const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
/** 型号变化:货号前缀始终同步为新型号(对齐 v1 web:货号 = 型号-后缀)。
* 货号为空 → 带入「型号-」;货号非空 → 替换第一个「-」前的前缀、保留后缀;
* 型号清空时货号保持不动(避免误删已填后缀)。 */
const onModelChange = (v: string) => {
setModelCode(v);
setOfferId((prev) => {
if (!prev) return v ? `${v}-` : '';
if (!v) return prev;
const idx = prev.indexOf('-');
const suffix = idx >= 0 ? prev.slice(idx) : '-';
return `${v}${suffix}`;
});
};
const openUrl = purchaseUrl?.trim()
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
: '';
const copyPurchaseUrl = () => {
copyText(purchaseUrl).then((ok) => ok && message.success('已复制'));
};
return (
<div>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel></FieldLabel>
<Row gutter={24}>
{/* ── 左列:中文标题 / 采集价 / 型号 / 货号 / 采买地址 ── */}
<Col span={11}>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Input
value={titleZh}
placeholder="采集原标题可在此整理为中文"
onChange={(e) => setTitleZh(e.target.value)}
onBlur={() => patchRaw({ title_zh: titleZh.trim() })}
/>
</Col>
<Col span={12}>
</div>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{raw.price ? String(raw.price) : '—'}</Text>
</div>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel></FieldLabel>
<Input
value={modelCode}
placeholder="如 YZ"
onChange={(e) => onModelChange(e.target.value)}
onBlur={() => patchRaw({ model_code: modelCode.trim() })}
/>
</Col>
<Col span={12}>
<FieldLabel>SKU</FieldLabel>
<Input
value={offerId}
placeholder="型号-后缀(型号自动带入前缀)"
onChange={(e) => setOfferId(e.target.value)}
onBlur={() => onSave({ offer_id: offerId.trim() })}
/>
</Col>
</Row>
<div style={{ ...fieldRowStyle, marginBottom: 0 }}>
<FieldLabel>1688 / </FieldLabel>
<Input
value={purchaseUrl}
placeholder="https://detail.1688.com/..."
onChange={(e) => setPurchaseUrl(e.target.value)}
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
addonAfter={
<span style={{ display: 'inline-flex', gap: 10 }}>
{openUrl && (
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址">
<ExportOutlined />
</a>
)}
<a title="复制采买地址" onClick={copyPurchaseUrl}>
<CopyOutlined />
</a>
</span>
}
/>
</div>
</Col>
{/* ── 右列:俄文标题 / 商品描述 / 规格参数 ── */}
<Col span={13}>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Input
value={nameRu}
onChange={(e) => setNameRu(e.target.value)}
onBlur={() => onSave({ name: nameRu })}
/>
</Col>
</Row>
</div>
<Row gutter={16} style={fieldRowStyle}>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.price)}</Text>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.brand)}</Text>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.sales)}</Text>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
<Text style={{ lineHeight: '32px' }}>{readOnly(raw.shop)}</Text>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={4}>
<FieldLabel> g</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={weightG}
onChange={(v) => setWeightG(v)}
onBlur={() => onSave({ weight: weightG ?? null, weight_unit: 'g' })}
/>
</Col>
<Col span={3}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.l}
onChange={(v) => setDims((d) => ({ ...d, l: v }))}
onBlur={() =>
onSave({
depth: dims.l == null ? null : dims.l * 10,
dimension_unit: 'mm',
})
}
/>
</Col>
<Col span={3}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.w}
onChange={(v) => setDims((d) => ({ ...d, w: v }))}
onBlur={() =>
onSave({
width: dims.w == null ? null : dims.w * 10,
dimension_unit: 'mm',
})
}
/>
</Col>
<Col span={3}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.h}
onChange={(v) => setDims((d) => ({ ...d, h: v }))}
onBlur={() =>
onSave({
height: dims.h == null ? null : dims.h * 10,
dimension_unit: 'mm',
})
}
/>
</Col>
<Col span={5}>
<FieldLabel>SKU</FieldLabel>
<Input
value={offerId}
placeholder="型号-后缀"
onChange={(e) => setOfferId(e.target.value)}
onBlur={() => onSave({ offer_id: offerId.trim() })}
/>
</Col>
<Col span={6}>
<FieldLabel></FieldLabel>
{product.source_url ? (
<a href={product.source_url} target="_blank" rel="noreferrer" style={{ lineHeight: '32px' }}>
{product.source_platform || '来源'}
</a>
) : (
<Text style={{ lineHeight: '32px' }}></Text>
)}
</Col>
</Row>
<div style={fieldRowStyle}>
<FieldLabel>1688 / </FieldLabel>
<Input
value={purchaseUrl}
placeholder="https://detail.1688.com/..."
onChange={(e) => setPurchaseUrl(e.target.value)}
onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })}
addonAfter={
<span style={{ display: 'inline-flex', gap: 10 }}>
{openUrl && (
<a href={openUrl} target="_blank" rel="noreferrer" title="打开采买地址">
<ExportOutlined />
</a>
)}
<a
title="复制采买地址"
onClick={() => {
copyText(purchaseUrl).then((ok) => ok && message.success('已复制'));
}}
>
<CopyOutlined />
</a>
</span>
}
/>
</div>
{params.length > 0 && (
<div style={fieldRowStyle}>
<Collapse
ghost
items={[
{
key: 'params',
label: <Text strong> / {params.length} </Text>,
children: (
<div style={{ maxHeight: 260, overflow: 'auto' }}>
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
<tbody>
{params.slice(0, 60).map((p, i) => (
<tr key={i} style={{ borderBottom: '1px solid #f0f0f0' }}>
<td style={{ padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap', width: 160 }}>
{p.key}
</td>
<td style={{ padding: '4px 0' }}>{p.value}</td>
</tr>
))}
</tbody>
</table>
</div>
),
},
]}
<FieldLabel></FieldLabel>
<Input.TextArea
rows={4}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onBlur={() => patchRaw({ desc })}
/>
</div>
)}
<div style={{ ...fieldRowStyle, marginBottom: 0 }}>
<FieldLabel></FieldLabel>
<Input.TextArea
rows={5}
value={desc}
onChange={(e) => setDesc(e.target.value)}
onBlur={() => patchRaw({ desc })}
/>
</div>
</div>
{params.length > 0 && (
<div>
<FieldLabel> / {params.length} </FieldLabel>
<div
style={{
maxHeight: 300,
overflow: 'auto',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: '6px 12px',
background: '#fafafa',
}}
>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', columnGap: 24 }}>
{params.slice(0, 60).map((p, i) => (
<div key={i} style={{ display: 'flex', borderBottom: '1px solid #f0f0f0', fontSize: 12, lineHeight: '28px' }}>
<span style={{ color: '#888', whiteSpace: 'nowrap', marginRight: 8, flexShrink: 0 }}>{p.key}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={p.value}>
{p.value}
</span>
</div>
))}
</div>
</div>
</div>
)}
</Col>
</Row>
);
}
+10 -9
View File
@@ -21,7 +21,9 @@ const SECTIONS = [
{ id: 'section-info', label: '商品信息' },
{ id: 'section-pricing', label: '价格试算' },
{ id: 'section-copy', label: '俄文文案' },
{ id: 'section-images', label: '图片与AI生图' },
{ id: 'section-collect', label: '采集图片' },
{ id: 'section-plan', label: '出图方案' },
{ id: 'section-result', label: '生成结果' },
{ id: 'section-export', label: '入库与导出' },
];
@@ -119,25 +121,24 @@ export default function TrialPage() {
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
<div id="section-info">
<Card title="01 商品信息">
<Card title="1 商品信息">
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
</Card>
</div>
<div id="section-pricing">
<Card title="02 价格试算">
<Card title="2 价格试算">
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
</Card>
</div>
<div id="section-copy">
<Card title="03 俄文文案(AI">
<CopyPanel product={product} onSave={save} />
<Card title="3 俄文文案(AI">
<CopyPanel product={product} onSave={save} leftSpan={11} rightSpan={13} />
</Card>
</div>
<div id="section-images">
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
</div>
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
<div id="section-export">
<Card title="05 入库与导出">
<Card title="7 入库与导出">
<TrialExportPanel product={product} />
</Card>
</div>
+110 -54
View File
@@ -28,23 +28,20 @@ interface Props {
}
type PricingPayload = Record<string, any>;
type Dims = { l: number | null; w: number | null; h: number | null };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number }>;
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number; weightG: number; dims: Dims }>;
function weightGrams(product: ProductDetail): number {
const w = product.weight ?? 0;
return product.weight_unit === 'kg' ? w * 1000 : w;
}
function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
function dimsCm(product: ProductDetail): Dims {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
return {
l: (product.depth ?? 0) * scale,
w: (product.width ?? 0) * scale,
h: (product.height ?? 0) * scale,
};
const cv = (v: number | null | undefined) => (v == null ? null : Number((v * scale).toFixed(2)));
return { l: cv(product.depth ?? null), w: cv(product.width ?? null), h: cv(product.height ?? null) };
}
/** 计价输入用:空值按 0 */
const wG = (v: number | null): number => v ?? 0;
const dimsNum = (d: Dims): { l: number; w: number; h: number } => ({ l: d.l ?? 0, w: d.w ?? 0, h: d.h ?? 0 });
/** 结果指标:可指定价格颜色(v1 完全成本/总抽成红色、销售价绿色) */
function Metric({ label, value, rule, color }: { label: string; value: string; rule?: string; color?: string }) {
return (
@@ -170,6 +167,11 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
const [fxSource, setFxSource] = useState('');
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
const [weight, setWeight] = useState<number | null>(
product.weight != null ? (product.weight_unit === 'kg' ? product.weight * 1000 : product.weight) : null,
);
const [dims, setDims] = useState<Dims>(dimsCm(product));
// 币种固定人民币(历史数据默认 RUB 时纠正一次)
useEffect(() => {
@@ -207,15 +209,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level,
weightG: weightGrams(product),
dims: dimsCm(product),
weightG: wG(patch.weightG ?? weight),
dims: dimsNum(patch.dims ?? dims),
tdPrice: patch.tdPrice ?? tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值) */
const recalc = (patch: PricingState = {}) => {
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段 */
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
@@ -223,20 +225,22 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
fxRate: patch.fxRate ?? fxRate,
weightG: wG(patch.weightG ?? weight),
dimsNow: dimsNum(patch.dims ?? dims),
};
const dims = dimsCm(product);
const dimError = validateDimensions(weightGrams(product), dims, p.level);
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level);
const r = dimError ? null : calculatePricing({
purchasePrice: p.purchasePrice,
profitRate: p.profitRate,
logisticsLevel: p.level,
weightG: weightGrams(product),
dims,
weightG: p.weightG,
dims: p.dimsNow,
tdPrice: p.tdPrice,
lineMultiplier: p.multiplier,
fxRate: p.fxRate,
});
onSave({
...extraSave,
pricing: {
...pricing,
purchasePrice: p.purchasePrice,
@@ -245,8 +249,8 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
logisticsLevel: p.level,
lineMultiplier: p.multiplier,
fxRate: p.fxRate,
weightG: weightGrams(product),
dims,
weightG: p.weightG,
dims: p.dimsNow,
...(r
? {
logisticsFee: r.logisticsFee,
@@ -269,7 +273,26 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
});
};
const dimError = fxRate ? validateDimensions(weightGrams(product), dimsCm(product), level) : '';
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
const onWeightChange = (v: number | null) => {
setWeight(v);
recalc({ weightG: v ?? 0 }, { weight: v, weight_unit: 'g' });
};
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
const onDimChange = (key: 'l' | 'w' | 'h', v: number | null) => {
const next = { ...dims, [key]: v };
setDims(next);
const field = key === 'l' ? 'depth' : key === 'w' ? 'width' : 'height';
recalc(
{ dims: next },
{ [field]: v == null ? null : v * 10, dimension_unit: 'mm' },
);
};
const curWeightG = wG(weight);
const curDims = dimsNum(dims);
const dimError = fxRate ? validateDimensions(curWeightG, curDims, level) : '';
const preview = fxRate && !dimError ? compute() : null;
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
@@ -279,8 +302,46 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
return (
<Row gutter={24}>
{/* ── 左:操作项(进货价 → 物流等级) ── */}
<Col span={10}>
{/* ── 左:操作项(重量/尺寸 → 物流等级) ── */}
<Col span={11}>
<Row gutter={16} style={fieldRowStyle}>
<Col span={6}>
<FieldLabel> g</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={weight}
onChange={(v) => onWeightChange(v)}
/>
</Col>
<Col span={6}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.l}
onChange={(v) => onDimChange('l', v)}
/>
</Col>
<Col span={6}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.w}
onChange={(v) => onDimChange('w', v)}
/>
</Col>
<Col span={6}>
<FieldLabel> cm</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
value={dims.h}
onChange={(v) => onDimChange('h', v)}
/>
</Col>
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel> ¥</FieldLabel>
@@ -339,6 +400,27 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/>
</Col>
</Row>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Radio.Group
block
optionType="button"
buttonStyle="solid"
value={level}
onChange={(e) => {
const v = e.target.value as LogisticsLevel;
setLevel(v);
recalc({ level: v });
}}
options={[
{ value: 'low', label: '低 (low)' },
{ value: 'high', label: '高 (high)' },
{ value: 'high2', label: 'Premium (high2)' },
]}
/>
</div>
{/* 汇率极少手动改(默认自动获取),放最后 */}
<Row gutter={16} style={fieldRowStyle}>
<Col span={13}>
<FieldLabel>
@@ -363,38 +445,12 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<Button icon={<ReloadOutlined />} onClick={refreshFx} title="刷新汇率" />
</Space.Compact>
</Col>
<Col span={11}>
<FieldLabel> / </FieldLabel>
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
</Text>
</Col>
</Row>
<div style={fieldRowStyle}>
<FieldLabel></FieldLabel>
<Radio.Group
block
optionType="button"
buttonStyle="solid"
value={level}
onChange={(e) => {
const v = e.target.value as LogisticsLevel;
setLevel(v);
recalc({ level: v });
}}
options={[
{ value: 'low', label: '低 (low)' },
{ value: 'high', label: '高 (high)' },
{ value: 'high2', label: 'Premium (high2)' },
]}
/>
</div>
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
线 = × (1 + %)/
线 = × (1 + %)/
</Text>
{(levelHint || rangeHint) && (
@@ -406,7 +462,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
</Col>
{/* ── 右:展示项(物流费 → 销售价) ── */}
<Col span={14}>
<Col span={13}>
<Card size="small" style={{ background: '#fafafa' }}>
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
<Row gutter={[16, 20]}>
@@ -414,7 +470,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<Metric
label="物流费"
value={preview ? `¥ ${num(preview.logisticsFee)}` : '--'}
rule={preview ? `(${logisticsFeeRule(weightGrams(product), level, tdPrice)})` : ''}
rule={preview ? `(${logisticsFeeRule(curWeightG, level, tdPrice)})` : ''}
/>
</Col>
<Col span={8}>
+65 -44
View File
@@ -5,7 +5,7 @@
*/
import { useEffect, useMemo, useRef, useState } from 'react';
import {
Alert, Button, Card, Checkbox, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
Alert, Button, Card, Checkbox, Col, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
} from 'antd';
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
@@ -444,10 +444,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{/* ── 采集图片 ── */}
{/* ── 4 采集图片 ── */}
<div id="section-collect">
<Card
size="small"
title={`采集图片(已选 ${selectedKeys.size} / ${selectableCount}`}
title={`4 采集图片(已选 ${selectedKeys.size} / ${selectableCount}`}
extra={
<Space>
{uploadButton}
@@ -569,36 +569,46 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Image.PreviewGroup>
)}
</Card>
</div>
{/* ── 出图方案 ── */}
{/* ── 5 出图方案(左:规划操作+方案列表;右:风格/要求/模型/生成) ── */}
<div id="section-plan">
<Card
size="small"
title={`出图方案(共 ${totalPlanned} 张)`}
extra={
<Space>
{planSource === 'ai' && <Tag color="purple">AI </Tag>}
<Checkbox
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
title="勾选后,AI 规划完成将自动开始生成"
>
</Checkbox>
<Button
size="small"
type="primary"
ghost
icon={<ThunderboltOutlined />}
loading={planning}
onClick={handlePlan}
>
AI
</Button>
</Space>
}
title={`5 出图方案(共 ${totalPlanned} 张)`}
extra={planSource === 'ai' && <Tag color="purple">AI </Tag>}
>
<div>
{plan.map((p, idx) => (
<Row gutter={24}>
<Col span={12}>
{/* 左列整块:与价格试算右列相同的浅灰卡片 */}
<Card size="small" style={{ background: '#fafafa' }}>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<Checkbox
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
title="勾选后,AI 规划完成将自动开始生成"
>
</Checkbox>
<Button
size="small"
type="primary"
ghost
icon={<ThunderboltOutlined />}
loading={planning}
onClick={handlePlan}
>
AI
</Button>
</div>
<div>
{plan.map((p, idx) => (
<div
key={`${p.kind}-${idx}`}
onClick={() => togglePlanRow(idx)}
@@ -664,20 +674,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
</Text>
</div>
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 4 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
<Text style={{ width: 70 }}></Text>
{/* 水印设置:左列最下、靠右 */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
</Button>
</Popover>
</div>
</Card>
</Col>
<Col span={12}>
<div>
<div style={{ marginBottom: 8 }}>
<div style={{ fontSize: 13, fontWeight: 500, color: 'rgba(0,0,0,0.82)', marginBottom: 6 }}>
</div>
<Segmented
value={styleSet}
onChange={(v) => setStyleSet(v as number)}
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
/>
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
<Button size="small" icon={<SettingOutlined />}>
</Button>
</Popover>
</div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
@@ -708,7 +725,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</Text>
<Input.TextArea
rows={2}
rows={3}
value={requirements}
onChange={(e) => setRequirements(e.target.value)}
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
@@ -749,13 +766,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</Button>
</Row>
</div>
</div>
</Col>
</Row>
</Card>
</div>
{/* ── 生成结果 ── */}
<div id="section-result">
<Card
size="small"
title="生成结果"
title="6 生成结果"
extra={
suite && ['done', 'partial'].includes(suite.status) && (
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
@@ -852,6 +872,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
</>
)}
</Card>
</div>
{/* 单张 AI 生图弹窗 */}
<AiImageGenModal