diff --git a/docs/v2.1/trial-page.md b/docs/v2.1/trial-page.md index 4b64e34..f922d63 100644 --- a/docs/v2.1/trial-page.md +++ b/docs/v2.1/trial-page.md @@ -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` 划线价倍数%(默认100,0-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×。线性、全程可算。 diff --git a/server/services/prompts/copy_ru.py b/server/services/prompts/copy_ru.py index 1d900a9..e96f590 100644 --- a/server/services/prompts/copy_ru.py +++ b/server/services/prompts/copy_ru.py @@ -55,6 +55,7 @@ SYSTEM_PROMPT = """你是一名熟悉俄罗斯消费者表达习惯、Ozon 商 【标签】 1. 输出10~15个标签;每个俄文标签必须是一个独立单词,不是短语,不带 #,不含标点,不把两个词用空格连接。 +2. 标签中绝对禁止出现任何特殊符号:禁止 - 连字符/中划线、_ 下划线、/ 斜杠、. 句点、, 逗号等一切非字母字符(俄文/英文字母之外的字符一律不允许);需要组合概念时用去掉连字符的合成词写法(例如"новогоднийподарок"式复合词或改用两个独立标签)。 2. 标签优先覆盖品类、造型、材质、功能、风格、摆放场景等高相关搜索概念,避免同词不同变格反复出现。 3. 中文标签也尽量为一个词;tags_ru 与 tags_zh 必须逐项语义对应。 diff --git a/studio/src/pages/product/CopyPanel.tsx b/studio/src/pages/product/CopyPanel.tsx index 7471f5c..5ce8f6d 100644 --- a/studio/src/pages/product/CopyPanel.tsx +++ b/studio/src/pages/product/CopyPanel.tsx @@ -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) => Promise | void; + /** 左右分栏 span(antd 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([]); const [model, setModel] = useState(''); 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 = { 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 ( - + {/* 显示中文原标题作为参考 */} {((raw.title_zh as string) || (raw.title as string)) && ( @@ -96,7 +112,7 @@ export default function CopyPanel({ product, onSave }: Props) { )} setSourceText(e.target.value)} placeholder="采集的商品资料(可编辑后生成)" @@ -114,7 +130,7 @@ export default function CopyPanel({ product, onSave }: Props) { - + {!result ? (
点击「生成简介」后在此查看,简介会自动回填
@@ -142,24 +158,36 @@ export default function CopyPanel({ product, onSave }: Props) { ghost onClick={() => applyTitle(t, result.titles_zh?.[i])} > - 回填 + 回填并复制 ))} - - 俄文简介 - - - - {result.description_ru} - + 俄文简介 + + + {/* 文本域展示(无滚动条自动撑高):复制时保留完整换行样式 */} + 标签 -
+ + (点击标签复制俄文) + +
{result.tags_ru.map((t, i) => ( - {t} + copyTag(t)} + > + {t} + {result.tags_zh?.[i] ? | {result.tags_zh[i]} : null} + ))}
- - {result.tags_zh?.join('、')} -
)}
diff --git a/studio/src/pages/trial/TrialExportPanel.tsx b/studio/src/pages/trial/TrialExportPanel.tsx index 3afccb8..c0a4aa9 100644 --- a/studio/src/pages/trial/TrialExportPanel.tsx +++ b/studio/src/pages/trial/TrialExportPanel.tsx @@ -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 { +function toRecord(product: ProductDetail): RegisterRecord | null { // eslint-disable-next-line @typescript-eslint/no-explicit-any - const pr = (p.pricing ?? {}) as Record; - const raw = (p.raw ?? {}) as Record; - 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; + const raw = (product.raw ?? {}) as Record; + 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 { 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; - 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 { - 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 | 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([]); + 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; + 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 => { + const money = (field: keyof RegisterRecord, fmt: (v: unknown) => string) => ({ + dataIndex: field, + render: (v: unknown) => {fmt(v)}, + }); + const base: ColumnsType = [ + { + title: '操作', + width: 70, + render: (_, r) => ( + deleteRecord(r.sku)}> + + + ), + }, + { title: '货号(sku)', dataIndex: 'sku', width: 110, render: (v: string) => {v || '--'} }, + { + title: '商品名', + dataIndex: 'productName', + ellipsis: true, + render: (v: string) => {v || '--'}, + }, + { title: '进货价', width: 90, ...money('purchasePrice', cny) }, + ]; + const optional: ColumnsType = [ + { title: '物流费', width: 95, ...money('logisticsFee', cny) }, + { title: '平台总抽成', width: 105, ...money('fullCommission', cny) }, + ]; + const rest: ColumnsType = [ + { 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) => {v ? `${v}%` : '--'} }, + { title: '卢布销价', width: 100, ...money('sellingPriceRub', rub) }, + ]; + const optional2: ColumnsType = [ + { title: '重量', dataIndex: 'weight', width: 85, render: (v: string) => {v ? `${v} g` : '--'} }, + { title: '尺寸', dataIndex: 'dimensions', width: 120, render: (v: string) => {v ? `${v} cm` : '--'} }, + { title: '状态', dataIndex: 'status', width: 75, render: (v: string) => {v || '在售'} }, + ]; + const links: ColumnsType = [ + { + title: 'Ozon地址', + dataIndex: 'sku', + width: 160, + render: (sku: string) => + sku ? ( + + {`https://www.ozon.ru/product/${sku}`} + + ) : ( + -- + ), + }, + { + title: '采买地址', + dataIndex: 'purchaseUrl', + width: 180, + ellipsis: true, + render: (v: string) => + v ? ( + + {v} + + ) : ( + -- + ), + }, + ]; + return showOptional ? [...base, ...optional, ...rest, ...optional2, ...links] : [...base, ...rest, ...links]; + }; + + const registerButton = (fixed = false) => ( + + ); + return (
- - - {pr.calculatedAt ? ( - 已入库 {new Date(pr.calculatedAt as string).toLocaleString()} - ) : ( - 未计价 - )} - - {product.offer_id || '—'} - - - {product.name || '—'} - - - + {/* 工具条:左=录入按钮;右=可选列切换/导出/清空(按钮配色对齐 v1) */} +
+ {registerButton()} +
+ + + + + + +
+
- - - - - - - - - 组合码格式:`货号 划线₽价`(无划线价回退销售价₽),用于 Ozon 后台批量改价; - CSV 为 16 列(对齐 v1 工具台),UTF-8 BOM 编码可直接用 Excel 打开。 - + + {/* 组合码弹窗(对齐 v1 上品组合码 textarea) */} } onClick={async () => { const ok = await copyText(comboText); ok ? message.success('已复制') : message.error('复制失败,请手动复制'); }} > - 复制全部 - , - , + , ]} > - + + + {/* 页面右下角固定录入按钮(不随页面滚动) */} + {registerButton(true)} ); } diff --git a/studio/src/pages/trial/TrialInfoPanel.tsx b/studio/src/pages/trial/TrialInfoPanel.tsx index 72858a9..e78d59f 100644 --- a/studio/src/pages/trial/TrialInfoPanel.tsx +++ b/studio/src/pages/trial/TrialInfoPanel.tsx @@ -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) => 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; 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(product.weight ?? null); - const [dims, setDims] = useState(dimsCm(product)); const patchRaw = (patch: Record) => 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 ( -
- -
- 标题(中文) + + {/* ── 左列:中文标题 / 采集价 / 型号 / 货号 / 采买地址 ── */} + +
+ 中文标题 setTitleZh(e.target.value)} onBlur={() => patchRaw({ title_zh: titleZh.trim() })} /> - -
+ + +
+ 采集价 + {raw.price ? String(raw.price) : '—'} +
+ + + + 型号 + onModelChange(e.target.value)} + onBlur={() => patchRaw({ model_code: modelCode.trim() })} + /> + + + 货号(SKU) + setOfferId(e.target.value)} + onBlur={() => onSave({ offer_id: offerId.trim() })} + /> + + + +
+ 采买地址(1688 / 拼多多等采购链接) + setPurchaseUrl(e.target.value)} + onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })} + addonAfter={ + + {openUrl && ( + + + + )} + + + + + } + /> +
+ + + {/* ── 右列:俄文标题 / 商品描述 / 规格参数 ── */} + +
俄文标题(文案生成可回填) setNameRu(e.target.value)} onBlur={() => onSave({ name: nameRu })} /> - - +
- - - 采集价 - {readOnly(raw.price)} - - - 品牌 - {readOnly(raw.brand)} - - - 销量 - {readOnly(raw.sales)} - - - 店铺 - {readOnly(raw.shop)} - - - - - - 重量 g - setWeightG(v)} - onBlur={() => onSave({ weight: weightG ?? null, weight_unit: 'g' })} - /> - - - 长 cm - setDims((d) => ({ ...d, l: v }))} - onBlur={() => - onSave({ - depth: dims.l == null ? null : dims.l * 10, - dimension_unit: 'mm', - }) - } - /> - - - 宽 cm - setDims((d) => ({ ...d, w: v }))} - onBlur={() => - onSave({ - width: dims.w == null ? null : dims.w * 10, - dimension_unit: 'mm', - }) - } - /> - - - 高 cm - setDims((d) => ({ ...d, h: v }))} - onBlur={() => - onSave({ - height: dims.h == null ? null : dims.h * 10, - dimension_unit: 'mm', - }) - } - /> - - - 货号(SKU) - setOfferId(e.target.value)} - onBlur={() => onSave({ offer_id: offerId.trim() })} - /> - - - 来源 - {product.source_url ? ( - - {product.source_platform || '来源'} 页面 - - ) : ( - - )} - - - -
- 采买地址(1688 / 拼多多等采购链接) - setPurchaseUrl(e.target.value)} - onBlur={() => patchRaw({ purchase_url: purchaseUrl.trim() })} - addonAfter={ - - {openUrl && ( - - - - )} - { - copyText(purchaseUrl).then((ok) => ok && message.success('已复制')); - }} - > - - - - } - /> -
- - {params.length > 0 && (
- 规格 / 参数({params.length} 项), - children: ( -
-
- - {params.slice(0, 60).map((p, i) => ( - - - - - ))} - -
- {p.key} - {p.value}
-
- ), - }, - ]} + 商品描述(供文案生成与生图上下文) + setDesc(e.target.value)} + onBlur={() => patchRaw({ desc })} /> - )} -
- 商品描述(供文案生成与生图上下文) - setDesc(e.target.value)} - onBlur={() => patchRaw({ desc })} - /> -
- + {params.length > 0 && ( +
+ 规格 / 参数({params.length} 项) +
+
+ {params.slice(0, 60).map((p, i) => ( +
+ {p.key} + + {p.value} + +
+ ))} +
+
+
+ )} + +
); } diff --git a/studio/src/pages/trial/TrialPage.tsx b/studio/src/pages/trial/TrialPage.tsx index 2d7a619..5d2ba83 100644 --- a/studio/src/pages/trial/TrialPage.tsx +++ b/studio/src/pages/trial/TrialPage.tsx @@ -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 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
- +
- +
- - + +
-
- -
+ {/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */} +
- +
diff --git a/studio/src/pages/trial/TrialPricingPanel.tsx b/studio/src/pages/trial/TrialPricingPanel.tsx index 00fdc5d..eeae173 100644 --- a/studio/src/pages/trial/TrialPricingPanel.tsx +++ b/studio/src/pages/trial/TrialPricingPanel.tsx @@ -28,23 +28,20 @@ interface Props { } type PricingPayload = Record; +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( + product.weight != null ? (product.weight_unit === 'kg' ? product.weight * 1000 : product.weight) : null, + ); + const [dims, setDims] = useState(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) => { 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 ( - {/* ── 左:操作项(进货价 → 物流等级) ── */} - + {/* ── 左:操作项(重量/尺寸 → 物流等级) ── */} + + + + 重量 g + onWeightChange(v)} + /> + + + 长 cm + onDimChange('l', v)} + /> + + + 宽 cm + onDimChange('w', v)} + /> + + + 高 cm + onDimChange('h', v)} + /> + + 进货价 ¥ @@ -339,6 +400,27 @@ export default function TrialPricingPanel({ product, onSave }: Props) { /> +
+ 物流等级 + { + 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)' }, + ]} + /> +
+ + {/* 汇率极少手动改(默认自动获取),放最后 */} @@ -363,38 +445,12 @@ export default function TrialPricingPanel({ product, onSave }: Props) { - - } + title={`5 出图方案(共 ${totalPlanned} 张)`} + extra={planSource === 'ai' && AI 方案} > -
- {plan.map((p, idx) => ( + + + {/* 左列整块:与价格试算右列相同的浅灰卡片 */} + +
+ setPlanThenGenerate(e.target.checked)} + title="勾选后,AI 规划完成将自动开始生成" + > + 规划并生成 + + +
+
+ {plan.map((p, idx) => (
togglePlanRow(idx)} @@ -664,20 +674,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr {planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
- -
-
- 视觉风格 + {/* 水印设置:左列最下、靠右 */} +
+ + + +
+ + + +
+
+
+ 视觉风格 +
setStyleSet(v as number)} options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} /> - - -
@@ -708,7 +725,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr 生图要求(优先级最高,强制约束,会覆盖其他设定) setRequirements(e.target.value)} placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印" @@ -749,13 +766,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr {generating ? '生成中…' : `一键生图(${totalPlanned} 张)`} -
+
+ + +
{/* ── 生成结果 ── */} +
} loading={exportingZip} onClick={handleExportZip}> @@ -852,6 +872,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr )} +
{/* 单张 AI 生图弹窗 */}