feat: 商品试算页面前端部分开发完成
This commit is contained in:
@@ -24,11 +24,11 @@
|
|||||||
┌──────────────────────────────────────────────────────┬────────────┐
|
┌──────────────────────────────────────────────────────┬────────────┐
|
||||||
│ 标题(中文/俄文) · stage 标签 · 保存状态 · 来源链接 · 去编辑页 │ 区域导航 │
|
│ 标题(中文/俄文) · stage 标签 · 保存状态 · 来源链接 · 去编辑页 │ 区域导航 │
|
||||||
├──────────────────────────────────────────────────────┤ (锚点滚动) │
|
├──────────────────────────────────────────────────────┤ (锚点滚动) │
|
||||||
│ 01 商品信息 标题/采集价/品牌/销量/店铺/重量/尺寸/货号/ │ │
|
│ 01 商品信息 标题/采集价/品牌/销量/店铺/货号/采买地址/ │ │
|
||||||
│ 采买地址/参数表/描述(可编辑,onBlur 落库) │ │
|
│ 参数表/描述(可编辑,onBlur 落库) │ │
|
||||||
│ 02 价格试算 进货价/净利率/贴单费/物流等级/预留折扣/汇率 → │ │
|
│ 02 价格试算 重量/尺寸/进货价/净利率/贴单费/物流等级/划线价 │ │
|
||||||
│ 结果卡(物流费→实收价/净利润→完全成本/总抽成→ │ │
|
│ 倍数/汇率 → 结果卡(物流费→实收价/净利润→完全 │ │
|
||||||
│ 销售价¥/₽/预留价) + 三类告警 │ │
|
│ 成本/总抽成→销售价¥/₽/划线价) + 告警 │ │
|
||||||
│ 03 俄文文案 商品资料 → 模型选择 → 生成标题/简介/标签 → 回填 │ │
|
│ 03 俄文文案 商品资料 → 模型选择 → 生成标题/简介/标签 → 回填 │ │
|
||||||
│ 04 图片与AI生图 采集图片(分组勾选/上传/AI生图) + 出图方案(规划/ │ │
|
│ 04 图片与AI生图 采集图片(分组勾选/上传/AI生图) + 出图方案(规划/ │ │
|
||||||
│ 风格/要求/模型/一键生成+进度) + 生成结果(导出) │ → image-suite.md │
|
│ 风格/要求/模型/一键生成+进度) + 生成结果(导出) │ → image-suite.md │
|
||||||
@@ -45,8 +45,8 @@
|
|||||||
| 标题(中文) | `raw.title_zh`(回退展示 `raw.title`) | 采集原标题写入 `raw.title`,人工整理后的中文名写 `title_zh` |
|
| 标题(中文) | `raw.title_zh`(回退展示 `raw.title`) | 采集原标题写入 `raw.title`,人工整理后的中文名写 `title_zh` |
|
||||||
| 俄文标题 | `name` | 文案生成回填,也可手改 |
|
| 俄文标题 | `name` | 文案生成回填,也可手改 |
|
||||||
| 采集价 / 品牌 / 销量 / 店铺 | `raw.price` / `raw.brand` / `raw.sales` / `raw.shop` | 只读展示 |
|
| 采集价 / 品牌 / 销量 / 店铺 | `raw.price` / `raw.brand` / `raw.sales` / `raw.shop` | 只读展示 |
|
||||||
| 重量 g | `weight`(`weight_unit='g'`) | 采集已自动 kg→g;试算计价直接用 |
|
| 重量 g | `weight`(`weight_unit='g'`) | 输入在 **02 价格试算左列顶部**(计价强相关);采集已自动 kg→g;变化即重算落库 |
|
||||||
| 长×宽×高 cm | `depth/width/height`(`dimension_unit='mm'`,cm×10 落库) | 计价与尺寸校验用 |
|
| 长×宽×高 cm | `depth/width/height`(`dimension_unit='mm'`,cm×10 落库) | 同上,计价与尺寸校验用 |
|
||||||
| 货号(SKU) | `offer_id` | 对齐 v1「型号-后缀」,v2.1 用单字段;CSV/组合码的主键 |
|
| 货号(SKU) | `offer_id` | 对齐 v1「型号-后缀」,v2.1 用单字段;CSV/组合码的主键 |
|
||||||
| 采买地址 | `raw.purchase_url` | 1688/淘宝采集时默认取 `source_url`;可改;导出 CSV 列 |
|
| 采买地址 | `raw.purchase_url` | 1688/淘宝采集时默认取 `source_url`;可改;导出 CSV 列 |
|
||||||
| 参数表 | `raw.params`(`[{key,value}]`) | 只读折叠展示;供文案/生图上下文 |
|
| 参数表 | `raw.params`(`[{key,value}]`) | 只读折叠展示;供文案/生图上下文 |
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
|
|
||||||
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
|
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
|
||||||
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认100,0-500)、
|
`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% 无解,
|
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
|
||||||
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
|
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ SYSTEM_PROMPT = """你是一名熟悉俄罗斯消费者表达习惯、Ozon 商
|
|||||||
|
|
||||||
【标签】
|
【标签】
|
||||||
1. 输出10~15个标签;每个俄文标签必须是一个独立单词,不是短语,不带 #,不含标点,不把两个词用空格连接。
|
1. 输出10~15个标签;每个俄文标签必须是一个独立单词,不是短语,不带 #,不含标点,不把两个词用空格连接。
|
||||||
|
2. 标签中绝对禁止出现任何特殊符号:禁止 - 连字符/中划线、_ 下划线、/ 斜杠、. 句点、, 逗号等一切非字母字符(俄文/英文字母之外的字符一律不允许);需要组合概念时用去掉连字符的合成词写法(例如"новогоднийподарок"式复合词或改用两个独立标签)。
|
||||||
2. 标签优先覆盖品类、造型、材质、功能、风格、摆放场景等高相关搜索概念,避免同词不同变格反复出现。
|
2. 标签优先覆盖品类、造型、材质、功能、风格、摆放场景等高相关搜索概念,避免同词不同变格反复出现。
|
||||||
3. 中文标签也尽量为一个词;tags_ru 与 tags_zh 必须逐项语义对应。
|
3. 中文标签也尽量为一个词;tags_ru 与 tags_zh 必须逐项语义对应。
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
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 { generateCopy, getAiModels, AiModelOption, CopyResponse } from '@/services/ai';
|
||||||
import { ProductDetail } from '@/services/product';
|
import { ProductDetail } from '@/services/product';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { apiErrorMessage } from '@/services/api';
|
||||||
|
import { copyText } from '@/utils/file';
|
||||||
|
|
||||||
const { Text, Paragraph } = Typography;
|
const { Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
/** 标签轮换配色(antd 预设色) */
|
||||||
|
const TAG_PRESETS = ['magenta', 'red', 'volcano', 'orange', 'gold', 'lime', 'green', 'cyan', 'blue', 'geekblue', 'purple'];
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
product: ProductDetail;
|
product: ProductDetail;
|
||||||
onSave: (p: Partial<ProductDetail>) => Promise<void> | void;
|
onSave: (p: Partial<ProductDetail>) => Promise<void> | 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<AiModelOption[]>([]);
|
const [models, setModels] = useState<AiModelOption[]>([]);
|
||||||
const [model, setModel] = useState<string>('');
|
const [model, setModel] = useState<string>('');
|
||||||
const [sourceText, setSourceText] = 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 applyTitle = async (titleRu: string, titleZh?: string) => {
|
||||||
const patch: Partial<ProductDetail> = { name: titleRu };
|
const patch: Partial<ProductDetail> = { name: titleRu };
|
||||||
if (titleZh) {
|
if (titleZh) {
|
||||||
patch.raw = { ...raw, title_zh: titleZh };
|
patch.raw = { ...raw, title_zh: titleZh };
|
||||||
}
|
}
|
||||||
await onSave(patch);
|
await onSave(patch);
|
||||||
message.success('标题已回填(中俄双语)');
|
const ok = await copyText(titleRu);
|
||||||
|
message.success(ok ? '已回填标题并复制俄文' : '已回填标题(复制失败,请手动复制)');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 回填俄文简介并复制到剪贴板 */
|
||||||
const applyDesc = async () => {
|
const applyDesc = async () => {
|
||||||
if (!result) return;
|
if (!result) return;
|
||||||
await onSave({ description: result.description_ru });
|
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 (
|
return (
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={10}>
|
<Col span={leftSpan}>
|
||||||
<Card size="small" title="采集信息(AI 输入)">
|
<Card size="small" title="采集信息(AI 输入)">
|
||||||
{/* 显示中文原标题作为参考 */}
|
{/* 显示中文原标题作为参考 */}
|
||||||
{((raw.title_zh as string) || (raw.title as string)) && (
|
{((raw.title_zh as string) || (raw.title as string)) && (
|
||||||
@@ -96,7 +112,7 @@ export default function CopyPanel({ product, onSave }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={12}
|
rows={16}
|
||||||
value={sourceText}
|
value={sourceText}
|
||||||
onChange={(e) => setSourceText(e.target.value)}
|
onChange={(e) => setSourceText(e.target.value)}
|
||||||
placeholder="采集的商品资料(可编辑后生成)"
|
placeholder="采集的商品资料(可编辑后生成)"
|
||||||
@@ -114,7 +130,7 @@ export default function CopyPanel({ product, onSave }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={14}>
|
<Col span={rightSpan}>
|
||||||
<Card size="small" title="生成结果">
|
<Card size="small" title="生成结果">
|
||||||
{!result ? (
|
{!result ? (
|
||||||
<div style={{ color: '#999' }}>点击「生成简介」后在此查看,简介会自动回填</div>
|
<div style={{ color: '#999' }}>点击「生成简介」后在此查看,简介会自动回填</div>
|
||||||
@@ -142,24 +158,36 @@ export default function CopyPanel({ product, onSave }: Props) {
|
|||||||
ghost
|
ghost
|
||||||
onClick={() => applyTitle(t, result.titles_zh?.[i])}
|
onClick={() => applyTitle(t, result.titles_zh?.[i])}
|
||||||
>
|
>
|
||||||
回填
|
回填并复制
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space style={{ marginTop: 4 }}>
|
<div
|
||||||
<Text strong>俄文简介</Text>
|
style={{
|
||||||
<Button size="small" onClick={applyDesc}>
|
marginTop: 4,
|
||||||
重新回填
|
marginBottom: 4,
|
||||||
</Button>
|
display: 'flex',
|
||||||
</Space>
|
justifyContent: 'space-between',
|
||||||
<Paragraph
|
alignItems: 'center',
|
||||||
style={{ whiteSpace: 'pre-wrap', marginTop: 4, marginBottom: 4, fontSize: 13 }}
|
// 与推荐标题灰底卡片(padding: 8)内的按钮右缘对齐
|
||||||
|
paddingRight: 8,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{result.description_ru}
|
<Text strong>俄文简介</Text>
|
||||||
</Paragraph>
|
<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
|
<Paragraph
|
||||||
type="secondary"
|
type="secondary"
|
||||||
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginBottom: 8 }}
|
style={{ whiteSpace: 'pre-wrap', fontSize: 12, marginBottom: 8 }}
|
||||||
@@ -168,14 +196,22 @@ export default function CopyPanel({ product, onSave }: Props) {
|
|||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
|
||||||
<Text strong>标签</Text>
|
<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) => (
|
{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>
|
</div>
|
||||||
<Text type="secondary" style={{ fontSize: 12, display: 'block' }}>
|
|
||||||
{result.tags_zh?.join('、')}
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,87 +1,148 @@
|
|||||||
/**
|
/**
|
||||||
* 05 入库与导出(docs/v2.1/trial-page.md §7):
|
* 7 入库与导出(对齐 v1 web/ozonSeller.html 上品登记表):
|
||||||
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 划线₽价)。
|
* 「录入当前商品」把当前试算商品登记进当日登记表(localStorage,v1 同款交互),
|
||||||
* Phase A 由前端拼装(逐个拉详情),Phase D 可换 GET /api/export/trial-csv。
|
* 表格展示当日录入的商品(17 列,物流费/平台总抽成/重量/尺寸/状态为可选列),
|
||||||
|
* 支持导出组合码 / 导出记录 CSV / 清空记录;页面右下角固定第二个录入按钮。
|
||||||
*/
|
*/
|
||||||
import { useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Button, Descriptions, Input, message, Modal, Space, Tag, Typography } from 'antd';
|
import { Button, Input, message, Modal, Popconfirm, Table, Typography } from 'antd';
|
||||||
import { CopyOutlined, DownloadOutlined, FileTextOutlined } from '@ant-design/icons';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import { getProduct, listProducts, ProductDetail } from '@/services/product';
|
import { CopyOutlined, DownloadOutlined, FileTextOutlined, PlusOutlined, RestOutlined } from '@ant-design/icons';
|
||||||
import { apiErrorMessage } from '@/services/api';
|
import { ProductDetail } from '@/services/product';
|
||||||
import { copyText, downloadCsv } from '@/utils/file';
|
import { copyText, downloadCsv } from '@/utils/file';
|
||||||
import { STAGE_LABEL } from '../collection/CollectionPage';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
const CSV_HEADER = [
|
/** v1 按钮/金额配色 */
|
||||||
'货号', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
|
const COLOR_SECONDARY = '#10b981'; // v1 bg-secondary(录入按钮)
|
||||||
'净利润', '净利率', '卢布销价', '重量(g)', '尺寸(cm)', '状态', 'Ozon地址', '采买地址',
|
|
||||||
];
|
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
|
// 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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const pr = (p.pricing ?? {}) as Record<string, any>;
|
const pr = (product.pricing ?? {}) as Record<string, any>;
|
||||||
const raw = (p.raw ?? {}) as Record<string, unknown>;
|
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||||
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? p.name ?? '').trim();
|
if (!pr.calculatedAt) return null;
|
||||||
const scale = p.dimension_unit === 'cm' ? 1 : 0.1;
|
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
||||||
const dims =
|
const dim = (v: number | null | undefined) => (v == null ? '' : String(v * scale));
|
||||||
p.depth != null && p.width != null && p.height != null
|
const level = (pr.logisticsLevel as string) ?? 'low';
|
||||||
? `${p.depth * scale}×${p.width * scale}×${p.height * scale}`
|
const f = (v: unknown, digits = 2) => (v == null ? '' : Number(v).toFixed(digits));
|
||||||
: '';
|
return {
|
||||||
const n = (v: unknown, digits = 2) => (v == null || v === '' ? '' : Number(v).toFixed(digits));
|
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 [
|
return [
|
||||||
p.offer_id || '',
|
r.sku || '',
|
||||||
titleZh,
|
r.productName || '',
|
||||||
n(pr.purchasePrice),
|
r.purchasePrice || '',
|
||||||
n(pr.logisticsFee),
|
r.logisticsFee || '',
|
||||||
n(pr.fullCommission),
|
r.fullCommission || '',
|
||||||
n(pr.receivedPrice),
|
r.receivedPrice || '',
|
||||||
n(pr.totalCost),
|
r.totalCost || '',
|
||||||
n(pr.sellingPriceCny),
|
r.sellingPrice || '',
|
||||||
n(pr.profitPrice),
|
r.profit || '',
|
||||||
pr.profitRate != null ? pr.profitRate : '',
|
r.profitRate ? `${r.profitRate}%` : '',
|
||||||
n(pr.sellingPriceRub, 0),
|
r.sellingPriceRub || '',
|
||||||
p.weight ?? '',
|
r.weight || '',
|
||||||
dims,
|
r.dimensions || '',
|
||||||
STAGE_LABEL[p.stage] ?? p.stage,
|
r.status || '在售',
|
||||||
p.offer_id ? `https://www.ozon.ru/product/${p.offer_id}` : '',
|
r.sku ? `https://www.ozon.ru/product/${r.sku}` : '',
|
||||||
(raw.purchase_url as string) ?? '',
|
r.purchaseUrl || '',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 组合码一行:`货号 划线₽价(两位)`,无划线价回退销售价₽ */
|
/** 组合码一行:`货号 划线₽价(两位)`,无划线价回退销售价₽ */
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
function comboLine(r: RegisterRecord): string {
|
||||||
function comboCode(p: ProductDetail): string {
|
const rubPrice = r.sellingPriceRubReserved || r.sellingPriceRub;
|
||||||
const pr = (p.pricing ?? {}) as Record<string, any>;
|
if (!r.sku || !rubPrice) return '';
|
||||||
const rub = pr.linePriceRub ?? pr.reservedPriceRub ?? pr.sellingPriceRub;
|
return `${r.sku} ${Number(rubPrice).toFixed(2)}`;
|
||||||
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())}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -89,109 +150,208 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TrialExportPanel({ product }: 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 [comboOpen, setComboOpen] = useState(false);
|
||||||
const [comboText, setComboText] = useState('');
|
const [comboText, setComboText] = useState('');
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
useEffect(() => {
|
||||||
const pr = (product.pricing ?? {}) as Record<string, any>;
|
setRecords(loadRecords().filter((r) => isToday(r.registeredAt)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const exportCurrent = () => {
|
const currentRecord = toRecord(product);
|
||||||
if (!pr.calculatedAt) {
|
|
||||||
message.warning('尚未计价,请先在「价格试算」完成参数填写');
|
/** 录入当前商品(对齐 v1 recordData:货号必填、先计价、当日货号去重) */
|
||||||
|
const registerCurrent = useCallback(() => {
|
||||||
|
if (!currentRecord) {
|
||||||
|
message.warning('请先在「2 价格试算」完成计价,再录入商品');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
downloadCsv([CSV_HEADER, buildRow(product)], `试算_${product.offer_id || '商品'}_${stamp()}.csv`);
|
if (!currentRecord.sku) {
|
||||||
message.success('已导出本商品 CSV');
|
message.warning('请先填写货号(SKU),再录入商品');
|
||||||
};
|
|
||||||
|
|
||||||
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('缺少货号或计价结果(卢布划线价)');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ok = await copyText(code);
|
const list = loadRecords().filter((r) => isToday(r.registeredAt));
|
||||||
ok ? message.success(`已复制:${code}`) : message.error('复制失败,请手动复制');
|
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 () => {
|
const clearRecords = () => {
|
||||||
setBatchLoading(true);
|
saveRecords([]);
|
||||||
const hide = message.loading('正在汇总组合码…', 0);
|
setRecords([]);
|
||||||
try {
|
message.success('登记表已清空');
|
||||||
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 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Descriptions size="small" column={3} style={{ marginBottom: 16 }}>
|
{/* 工具条:左=录入按钮;右=可选列切换/导出/清空(按钮配色对齐 v1) */}
|
||||||
<Descriptions.Item label="计价入库">
|
<div style={{ display: 'flex', flexWrap: 'wrap', justifyContent: 'space-between', alignItems: 'center', gap: 12, marginBottom: 16 }}>
|
||||||
{pr.calculatedAt ? (
|
{registerButton()}
|
||||||
<Tag color="green">已入库 {new Date(pr.calculatedAt as string).toLocaleString()}</Tag>
|
<div style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'center', gap: 8 }}>
|
||||||
) : (
|
<Button
|
||||||
<Tag color="orange">未计价</Tag>
|
type={showOptional ? 'primary' : 'default'}
|
||||||
)}
|
ghost={showOptional}
|
||||||
</Descriptions.Item>
|
onClick={() => setShowOptional((v) => !v)}
|
||||||
<Descriptions.Item label="货号">{product.offer_id || '—'}</Descriptions.Item>
|
>
|
||||||
<Descriptions.Item label="俄文标题">
|
显示物流费/平台总抽成/重量/尺寸/状态
|
||||||
<Text ellipsis={{ tooltip: product.name }} style={{ maxWidth: 220 }}>
|
</Button>
|
||||||
{product.name || '—'}
|
<Button type="primary" ghost icon={<CopyOutlined />} onClick={openCombo}>
|
||||||
</Text>
|
导出组合码
|
||||||
</Descriptions.Item>
|
</Button>
|
||||||
</Descriptions>
|
<Button type="primary" ghost icon={<FileTextOutlined />} onClick={exportCsv}>
|
||||||
|
导出记录
|
||||||
|
</Button>
|
||||||
|
<Popconfirm title="确认表格已经导出!此操作将清空已有数据。" onConfirm={clearRecords}>
|
||||||
|
<Button danger ghost icon={<RestOutlined />}>清空记录</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Space wrap>
|
<Table
|
||||||
<Button type="primary" icon={<DownloadOutlined />} onClick={exportCurrent}>
|
rowKey="sku"
|
||||||
导出本商品 CSV
|
size="small"
|
||||||
</Button>
|
columns={buildColumns()}
|
||||||
<Button icon={<FileTextOutlined />} loading={batchLoading} onClick={exportBatch}>
|
dataSource={records}
|
||||||
批量导出 CSV(全部已计价)
|
pagination={false}
|
||||||
</Button>
|
scroll={{ x: 'max-content' }}
|
||||||
<Button icon={<CopyOutlined />} onClick={copyCurrentCombo}>
|
locale={{ emptyText: '当日暂无录入记录,点击「录入当前商品」登记' }}
|
||||||
复制本商品组合码
|
/>
|
||||||
</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>
|
|
||||||
|
|
||||||
|
{/* 组合码弹窗(对齐 v1 上品组合码 textarea) */}
|
||||||
<Modal
|
<Modal
|
||||||
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
|
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
|
||||||
open={comboOpen}
|
open={comboOpen}
|
||||||
@@ -200,20 +360,23 @@ export default function TrialExportPanel({ product }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
key="copy"
|
key="copy"
|
||||||
type="primary"
|
type="primary"
|
||||||
|
style={{ background: COLOR_SECONDARY }}
|
||||||
|
icon={<DownloadOutlined />}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const ok = await copyText(comboText);
|
const ok = await copyText(comboText);
|
||||||
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
|
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
复制全部
|
复制结果
|
||||||
</Button>,
|
|
||||||
<Button key="close" onClick={() => setComboOpen(false)}>
|
|
||||||
关闭
|
|
||||||
</Button>,
|
</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>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 页面右下角固定录入按钮(不随页面滚动) */}
|
||||||
|
{registerButton(true)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react';
|
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 { CopyOutlined, ExportOutlined } from '@ant-design/icons';
|
||||||
import { ProductDetail } from '@/services/product';
|
import { ProductDetail } from '@/services/product';
|
||||||
import { copyText } from '@/utils/file';
|
import { copyText } from '@/utils/file';
|
||||||
@@ -7,228 +7,170 @@ import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
|
|||||||
|
|
||||||
const { Text } = Typography;
|
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 {
|
interface Props {
|
||||||
product: ProductDetail;
|
product: ProductDetail;
|
||||||
onSave: (p: Partial<ProductDetail>) => void;
|
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)。
|
* 01 商品信息:采集结果核对/补录。编辑失焦即落库(父级 PATCH)。
|
||||||
* 重量/尺寸是计价与生图上下文的唯一真源(写 product 包装字段)。
|
* 左右两列:左=中文标题/采集价/型号/货号/采买地址,右=俄文标题/商品描述/规格参数。
|
||||||
|
* 重量/尺寸在「02 价格试算」左列维护;采买地址仅国内采购平台自动填入。
|
||||||
*/
|
*/
|
||||||
export default function TrialInfoPanel({ product, onSave }: Props) {
|
export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||||
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
|
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
|
||||||
const [nameRu, setNameRu] = useState(product.name ?? '');
|
const [nameRu, setNameRu] = useState(product.name ?? '');
|
||||||
|
const [modelCode, setModelCode] = useState((raw.model_code as string) ?? '');
|
||||||
const [offerId, setOfferId] = useState(product.offer_id ?? '');
|
const [offerId, setOfferId] = useState(product.offer_id ?? '');
|
||||||
|
// 采买地址:仅 1688/拼多多/淘宝/天猫 来源时用 source_url 初始化
|
||||||
const [purchaseUrl, setPurchaseUrl] = useState(
|
const [purchaseUrl, setPurchaseUrl] = useState(
|
||||||
(raw.purchase_url as string) ??
|
(raw.purchase_url as string) ?? (isPurchasePlatform(product.source_platform) ? (product.source_url ?? '') : ''),
|
||||||
(['1688', 'taobao'].includes(product.source_platform ?? '') ? (product.source_url ?? '') : ''),
|
|
||||||
);
|
);
|
||||||
const [desc, setDesc] = useState((raw.desc as string) ?? '');
|
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 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 }>) : [];
|
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()
|
const openUrl = purchaseUrl?.trim()
|
||||||
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
|
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
const copyPurchaseUrl = () => {
|
||||||
|
copyText(purchaseUrl).then((ok) => ok && message.success('已复制'));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<Row gutter={24}>
|
||||||
<Row gutter={16} style={fieldRowStyle}>
|
{/* ── 左列:中文标题 / 采集价 / 型号 / 货号 / 采买地址 ── */}
|
||||||
<Col span={12}>
|
<Col span={11}>
|
||||||
<FieldLabel>标题(中文)</FieldLabel>
|
<div style={fieldRowStyle}>
|
||||||
|
<FieldLabel>中文标题</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
value={titleZh}
|
value={titleZh}
|
||||||
placeholder="采集原标题可在此整理为中文"
|
placeholder="采集原标题可在此整理为中文"
|
||||||
onChange={(e) => setTitleZh(e.target.value)}
|
onChange={(e) => setTitleZh(e.target.value)}
|
||||||
onBlur={() => patchRaw({ title_zh: titleZh.trim() })}
|
onBlur={() => patchRaw({ title_zh: titleZh.trim() })}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</div>
|
||||||
<Col span={12}>
|
|
||||||
|
<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>
|
<FieldLabel>俄文标题(文案生成可回填)</FieldLabel>
|
||||||
<Input
|
<Input
|
||||||
value={nameRu}
|
value={nameRu}
|
||||||
onChange={(e) => setNameRu(e.target.value)}
|
onChange={(e) => setNameRu(e.target.value)}
|
||||||
onBlur={() => onSave({ name: nameRu })}
|
onBlur={() => onSave({ name: nameRu })}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</div>
|
||||||
</Row>
|
|
||||||
|
|
||||||
<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}>
|
<div style={fieldRowStyle}>
|
||||||
<Collapse
|
<FieldLabel>商品描述(供文案生成与生图上下文)</FieldLabel>
|
||||||
ghost
|
<Input.TextArea
|
||||||
items={[
|
rows={4}
|
||||||
{
|
value={desc}
|
||||||
key: 'params',
|
onChange={(e) => setDesc(e.target.value)}
|
||||||
label: <Text strong>规格 / 参数({params.length} 项)</Text>,
|
onBlur={() => patchRaw({ desc })}
|
||||||
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>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div style={{ ...fieldRowStyle, marginBottom: 0 }}>
|
{params.length > 0 && (
|
||||||
<FieldLabel>商品描述(供文案生成与生图上下文)</FieldLabel>
|
<div>
|
||||||
<Input.TextArea
|
<FieldLabel>规格 / 参数({params.length} 项)</FieldLabel>
|
||||||
rows={5}
|
<div
|
||||||
value={desc}
|
style={{
|
||||||
onChange={(e) => setDesc(e.target.value)}
|
maxHeight: 300,
|
||||||
onBlur={() => patchRaw({ desc })}
|
overflow: 'auto',
|
||||||
/>
|
border: '1px solid #f0f0f0',
|
||||||
</div>
|
borderRadius: 8,
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ const SECTIONS = [
|
|||||||
{ id: 'section-info', label: '商品信息' },
|
{ id: 'section-info', label: '商品信息' },
|
||||||
{ id: 'section-pricing', label: '价格试算' },
|
{ id: 'section-pricing', label: '价格试算' },
|
||||||
{ id: 'section-copy', 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: '入库与导出' },
|
{ id: 'section-export', label: '入库与导出' },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -119,25 +121,24 @@ export default function TrialPage() {
|
|||||||
|
|
||||||
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
|
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
|
||||||
<div id="section-info">
|
<div id="section-info">
|
||||||
<Card title="01 商品信息">
|
<Card title="1 商品信息">
|
||||||
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
|
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div id="section-pricing">
|
<div id="section-pricing">
|
||||||
<Card title="02 价格试算">
|
<Card title="2 价格试算">
|
||||||
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
|
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div id="section-copy">
|
<div id="section-copy">
|
||||||
<Card title="03 俄文文案(AI)">
|
<Card title="3 俄文文案(AI)">
|
||||||
<CopyPanel product={product} onSave={save} />
|
<CopyPanel product={product} onSave={save} leftSpan={11} rightSpan={13} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
<div id="section-images">
|
{/* 4/5/6 三张卡片由 TrialSuitePanel 渲染,锚点在面板内部 */}
|
||||||
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
|
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
|
||||||
</div>
|
|
||||||
<div id="section-export">
|
<div id="section-export">
|
||||||
<Card title="05 入库与导出">
|
<Card title="7 入库与导出">
|
||||||
<TrialExportPanel product={product} />
|
<TrialExportPanel product={product} />
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -28,23 +28,20 @@ interface Props {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PricingPayload = Record<string, any>;
|
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
|
// 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 {
|
function dimsCm(product: ProductDetail): Dims {
|
||||||
const w = product.weight ?? 0;
|
|
||||||
return product.weight_unit === 'kg' ? w * 1000 : w;
|
|
||||||
}
|
|
||||||
|
|
||||||
function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
|
|
||||||
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
||||||
return {
|
const cv = (v: number | null | undefined) => (v == null ? null : Number((v * scale).toFixed(2)));
|
||||||
l: (product.depth ?? 0) * scale,
|
return { l: cv(product.depth ?? null), w: cv(product.width ?? null), h: cv(product.height ?? null) };
|
||||||
w: (product.width ?? 0) * scale,
|
|
||||||
h: (product.height ?? 0) * scale,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 计价输入用:空值按 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 完全成本/总抽成红色、销售价绿色) */
|
/** 结果指标:可指定价格颜色(v1 完全成本/总抽成红色、销售价绿色) */
|
||||||
function Metric({ label, value, rule, color }: { label: string; value: string; rule?: string; color?: string }) {
|
function Metric({ label, value, rule, color }: { label: string; value: string; rule?: string; color?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -170,6 +167,11 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
|
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
|
||||||
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||||
const [fxSource, setFxSource] = useState('');
|
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 时纠正一次)
|
// 币种固定人民币(历史数据默认 RUB 时纠正一次)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -207,15 +209,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||||
profitRate: patch.profitRate ?? profitRate,
|
profitRate: patch.profitRate ?? profitRate,
|
||||||
logisticsLevel: patch.level ?? level,
|
logisticsLevel: patch.level ?? level,
|
||||||
weightG: weightGrams(product),
|
weightG: wG(patch.weightG ?? weight),
|
||||||
dims: dimsCm(product),
|
dims: dimsNum(patch.dims ?? dims),
|
||||||
tdPrice: patch.tdPrice ?? tdPrice,
|
tdPrice: patch.tdPrice ?? tdPrice,
|
||||||
lineMultiplier: patch.multiplier ?? multiplier,
|
lineMultiplier: patch.multiplier ?? multiplier,
|
||||||
fxRate: patch.fxRate ?? fxRate,
|
fxRate: patch.fxRate ?? fxRate,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值) */
|
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
|
||||||
const recalc = (patch: PricingState = {}) => {
|
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
|
||||||
const p = {
|
const p = {
|
||||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||||
profitRate: patch.profitRate ?? profitRate,
|
profitRate: patch.profitRate ?? profitRate,
|
||||||
@@ -223,20 +225,22 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
level: patch.level ?? level,
|
level: patch.level ?? level,
|
||||||
multiplier: patch.multiplier ?? multiplier,
|
multiplier: patch.multiplier ?? multiplier,
|
||||||
fxRate: patch.fxRate ?? fxRate,
|
fxRate: patch.fxRate ?? fxRate,
|
||||||
|
weightG: wG(patch.weightG ?? weight),
|
||||||
|
dimsNow: dimsNum(patch.dims ?? dims),
|
||||||
};
|
};
|
||||||
const dims = dimsCm(product);
|
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level);
|
||||||
const dimError = validateDimensions(weightGrams(product), dims, p.level);
|
|
||||||
const r = dimError ? null : calculatePricing({
|
const r = dimError ? null : calculatePricing({
|
||||||
purchasePrice: p.purchasePrice,
|
purchasePrice: p.purchasePrice,
|
||||||
profitRate: p.profitRate,
|
profitRate: p.profitRate,
|
||||||
logisticsLevel: p.level,
|
logisticsLevel: p.level,
|
||||||
weightG: weightGrams(product),
|
weightG: p.weightG,
|
||||||
dims,
|
dims: p.dimsNow,
|
||||||
tdPrice: p.tdPrice,
|
tdPrice: p.tdPrice,
|
||||||
lineMultiplier: p.multiplier,
|
lineMultiplier: p.multiplier,
|
||||||
fxRate: p.fxRate,
|
fxRate: p.fxRate,
|
||||||
});
|
});
|
||||||
onSave({
|
onSave({
|
||||||
|
...extraSave,
|
||||||
pricing: {
|
pricing: {
|
||||||
...pricing,
|
...pricing,
|
||||||
purchasePrice: p.purchasePrice,
|
purchasePrice: p.purchasePrice,
|
||||||
@@ -245,8 +249,8 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
logisticsLevel: p.level,
|
logisticsLevel: p.level,
|
||||||
lineMultiplier: p.multiplier,
|
lineMultiplier: p.multiplier,
|
||||||
fxRate: p.fxRate,
|
fxRate: p.fxRate,
|
||||||
weightG: weightGrams(product),
|
weightG: p.weightG,
|
||||||
dims,
|
dims: p.dimsNow,
|
||||||
...(r
|
...(r
|
||||||
? {
|
? {
|
||||||
logisticsFee: r.logisticsFee,
|
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 preview = fxRate && !dimError ? compute() : null;
|
||||||
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
|
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
|
||||||
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
|
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
|
||||||
@@ -279,8 +302,46 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={24}>
|
<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}>
|
<Row gutter={16} style={fieldRowStyle}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<FieldLabel>进货价 ¥</FieldLabel>
|
<FieldLabel>进货价 ¥</FieldLabel>
|
||||||
@@ -339,6 +400,27 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</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}>
|
<Row gutter={16} style={fieldRowStyle}>
|
||||||
<Col span={13}>
|
<Col span={13}>
|
||||||
<FieldLabel>
|
<FieldLabel>
|
||||||
@@ -363,38 +445,12 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<Button icon={<ReloadOutlined />} onClick={refreshFx} title="刷新汇率" />
|
<Button icon={<ReloadOutlined />} onClick={refreshFx} title="刷新汇率" />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={11}>
|
|
||||||
<FieldLabel>重量 / 尺寸</FieldLabel>
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
|
|
||||||
取自上方包装信息
|
|
||||||
</Text>
|
|
||||||
</Col>
|
|
||||||
</Row>
|
</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} />}
|
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
|
||||||
|
|
||||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||||
划线价 = 销售价 × (1 + 倍数%);参数变化自动计价并入库;尺寸不合规时不落计价结果。重量/尺寸在「商品信息」区块维护。
|
划线价 = 销售价 × (1 + 倍数%);参数与重量/尺寸变化自动计价并入库;尺寸不合规时不落计价结果。
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{(levelHint || rangeHint) && (
|
{(levelHint || rangeHint) && (
|
||||||
@@ -406,7 +462,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{/* ── 右:展示项(物流费 → 销售价) ── */}
|
{/* ── 右:展示项(物流费 → 销售价) ── */}
|
||||||
<Col span={14}>
|
<Col span={13}>
|
||||||
<Card size="small" style={{ background: '#fafafa' }}>
|
<Card size="small" style={{ background: '#fafafa' }}>
|
||||||
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
|
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
|
||||||
<Row gutter={[16, 20]}>
|
<Row gutter={[16, 20]}>
|
||||||
@@ -414,7 +470,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
|
|||||||
<Metric
|
<Metric
|
||||||
label="物流费"
|
label="物流费"
|
||||||
value={preview ? `¥ ${num(preview.logisticsFee)}` : '--'}
|
value={preview ? `¥ ${num(preview.logisticsFee)}` : '--'}
|
||||||
rule={preview ? `(${logisticsFeeRule(weightGrams(product), level, tdPrice)})` : ''}
|
rule={preview ? `(${logisticsFeeRule(curWeightG, level, tdPrice)})` : ''}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={8}>
|
<Col span={8}>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import {
|
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,
|
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||||
@@ -444,10 +444,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{/* ── 采集图片 ── */}
|
{/* ── 4 采集图片 ── */}
|
||||||
|
<div id="section-collect">
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
title={`4 采集图片(已选 ${selectedKeys.size} / ${selectableCount})`}
|
||||||
title={`采集图片(已选 ${selectedKeys.size} / ${selectableCount})`}
|
|
||||||
extra={
|
extra={
|
||||||
<Space>
|
<Space>
|
||||||
{uploadButton}
|
{uploadButton}
|
||||||
@@ -569,36 +569,46 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
</Image.PreviewGroup>
|
</Image.PreviewGroup>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 出图方案 ── */}
|
{/* ── 5 出图方案(左:规划操作+方案列表;右:风格/要求/模型/生成) ── */}
|
||||||
|
<div id="section-plan">
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
title={`5 出图方案(共 ${totalPlanned} 张)`}
|
||||||
title={`出图方案(共 ${totalPlanned} 张)`}
|
extra={planSource === 'ai' && <Tag color="purple">AI 方案</Tag>}
|
||||||
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>
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div>
|
<Row gutter={24}>
|
||||||
{plan.map((p, idx) => (
|
<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
|
<div
|
||||||
key={`${p.kind}-${idx}`}
|
key={`${p.kind}-${idx}`}
|
||||||
onClick={() => togglePlanRow(idx)}
|
onClick={() => togglePlanRow(idx)}
|
||||||
@@ -664,20 +674,27 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
|
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 水印设置:左列最下、靠右 */}
|
||||||
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 4 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 8 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
|
||||||
<Text style={{ width: 70 }}>视觉风格</Text>
|
<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
|
<Segmented
|
||||||
value={styleSet}
|
value={styleSet}
|
||||||
onChange={(v) => setStyleSet(v as number)}
|
onChange={(v) => setStyleSet(v as number)}
|
||||||
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
|
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>
|
||||||
<div style={{ marginBottom: 8 }}>
|
<div style={{ marginBottom: 8 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
@@ -708,7 +725,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
||||||
</Text>
|
</Text>
|
||||||
<Input.TextArea
|
<Input.TextArea
|
||||||
rows={2}
|
rows={3}
|
||||||
value={requirements}
|
value={requirements}
|
||||||
onChange={(e) => setRequirements(e.target.value)}
|
onChange={(e) => setRequirements(e.target.value)}
|
||||||
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
|
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
|
||||||
@@ -749,13 +766,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
|
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
|
||||||
</Button>
|
</Button>
|
||||||
</Row>
|
</Row>
|
||||||
</div>
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* ── 生成结果 ── */}
|
{/* ── 生成结果 ── */}
|
||||||
|
<div id="section-result">
|
||||||
<Card
|
<Card
|
||||||
size="small"
|
title="6 生成结果"
|
||||||
title="生成结果"
|
|
||||||
extra={
|
extra={
|
||||||
suite && ['done', 'partial'].includes(suite.status) && (
|
suite && ['done', 'partial'].includes(suite.status) && (
|
||||||
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
|
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
|
||||||
@@ -852,6 +872,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 单张 AI 生图弹窗 */}
|
{/* 单张 AI 生图弹窗 */}
|
||||||
<AiImageGenModal
|
<AiImageGenModal
|
||||||
|
|||||||
Reference in New Issue
Block a user