feat: 商品试算页
This commit is contained in:
@@ -50,6 +50,9 @@ export const getPageInfo = (path: string): { title: string; subtitle: string } =
|
||||
const first = routeMenuConfig[0];
|
||||
return { title: first.title, subtitle: first.subtitle };
|
||||
}
|
||||
if (path.startsWith('/trial/')) {
|
||||
return { title: '商品试算', subtitle: '计价、俄文文案、AI 生图、入库与导出' };
|
||||
}
|
||||
if (path.startsWith('/product/')) {
|
||||
return { title: '商品编辑', subtitle: '编辑商品信息、计价、文案与图片' };
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function CollectionPage() {
|
||||
title: '商品名',
|
||||
dataIndex: 'name',
|
||||
render: (v, r) => (
|
||||
<a onClick={() => navigate(`/product/${r.id}`)}>{v || '(未命名)'}</a>
|
||||
<a onClick={() => navigate(`/trial/${r.id}`)}>{v || '(未命名)'}</a>
|
||||
),
|
||||
},
|
||||
{ title: '货号', dataIndex: 'offer_id', width: 120, render: (v) => v || '—' },
|
||||
@@ -112,9 +112,12 @@ export default function CollectionPage() {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 210,
|
||||
width: 260,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<Button size="small" type="primary" ghost onClick={() => navigate(`/trial/${r.id}`)}>
|
||||
试算
|
||||
</Button>
|
||||
<Button size="small" onClick={() => navigate(`/product/${r.id}`)}>
|
||||
编辑
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Image, Input, message, Select, Space, Typography } from 'antd';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, imageEditSingle } from '@/services/suite';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
productId: string;
|
||||
/** 待生成的源图(采集图或生成图) */
|
||||
source: { url: string; name: string } | null;
|
||||
onClose: () => void;
|
||||
/** 生成成功回调(服务端 append 后刷新素材列表) */
|
||||
onGenerated?: (url: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
|
||||
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
|
||||
*/
|
||||
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated }: Props) {
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [resultUrl, setResultUrl] = useState('');
|
||||
|
||||
// 每次打开重置(保留模型选择,方便连续精修)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPrompt('');
|
||||
setResultUrl('');
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!source) return;
|
||||
if (!prompt.trim()) {
|
||||
message.warning('请先输入生图要求');
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
setResultUrl('');
|
||||
try {
|
||||
const r = await imageEditSingle({
|
||||
product_id: productId,
|
||||
image_url: source.url,
|
||||
prompt: prompt.trim(),
|
||||
model,
|
||||
append: true,
|
||||
});
|
||||
setResultUrl(r.url);
|
||||
message.success('生成完成,已追加到「生成图」分组');
|
||||
onGenerated?.(r.url);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: '#fff',
|
||||
borderRadius: 10,
|
||||
padding: 20,
|
||||
width: 760,
|
||||
maxWidth: '92vw',
|
||||
maxHeight: '88vh',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12, maxWidth: 360 }} ellipsis={{ tooltip: source?.name }}>
|
||||
{source?.name}
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ width: 260, flexShrink: 0 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>原图</Text>
|
||||
<Image
|
||||
src={source?.url}
|
||||
alt="原图"
|
||||
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={'输入生图要求,例如:把背景换成纯白色摄影棚,保留商品细节与比例;增加柔和阴影'}
|
||||
disabled={generating}
|
||||
/>
|
||||
<Space style={{ marginTop: 12 }} wrap>
|
||||
<Select
|
||||
style={{ width: 260 }}
|
||||
popupMatchSelectWidth={false}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={generating}
|
||||
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
{(option as { data?: { desc?: string } }).data?.desc}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button type="primary" loading={generating} onClick={onGenerate}>
|
||||
生成
|
||||
</Button>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</Space>
|
||||
<Alert
|
||||
style={{ marginTop: 16 }}
|
||||
type="info"
|
||||
showIcon
|
||||
message="生成结果会自动追加到「生成图」分组;GPT 系列单张 1-5 分钟,请耐心等待"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{resultUrl && (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space style={{ marginBottom: 8 }}>
|
||||
<Text strong>生成结果</Text>
|
||||
<a href={resultUrl} target="_blank" rel="noreferrer">
|
||||
打开原图
|
||||
</a>
|
||||
</Space>
|
||||
<Image src={resultUrl} style={{ borderRadius: 8, maxHeight: 360, objectFit: 'contain' }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 05 入库与导出(docs/v2.1/trial-page.md §7):
|
||||
* 入库即各区块自动落库;此处提供 本商品CSV / 批量CSV(16 列,对齐 v1 web)与 组合码(SKU 预留₽价)。
|
||||
* Phase A 由前端拼装(逐个拉详情),Phase D 可换 GET /api/export/trial-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 { copyText, downloadCsv } from '@/utils/file';
|
||||
import { STAGE_LABEL } from '../collection/CollectionPage';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const CSV_HEADER = [
|
||||
'货号', '商品名', '进货价', '物流费', '平台总抽成', '实收价', '完全成本', '销售价',
|
||||
'净利润', '净利率', '卢布销价', '重量(g)', '尺寸(cm)', '状态', 'Ozon地址', '采买地址',
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function buildRow(p: ProductDetail): Array<string | number | null> {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pr = (p.pricing ?? {}) as Record<string, any>;
|
||||
const raw = (p.raw ?? {}) as Record<string, unknown>;
|
||||
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? p.name ?? '').trim();
|
||||
const scale = p.dimension_unit === 'cm' ? 1 : 0.1;
|
||||
const dims =
|
||||
p.depth != null && p.width != null && p.height != null
|
||||
? `${p.depth * scale}×${p.width * scale}×${p.height * scale}`
|
||||
: '';
|
||||
const n = (v: unknown, digits = 2) => (v == null || v === '' ? '' : Number(v).toFixed(digits));
|
||||
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) ?? '',
|
||||
];
|
||||
}
|
||||
|
||||
/** 组合码一行:`货号 预留₽价(两位)`,无预留价回退销售价₽ */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function comboCode(p: ProductDetail): string {
|
||||
const pr = (p.pricing ?? {}) as Record<string, any>;
|
||||
const rub = pr.reservedPriceRub ?? pr.sellingPriceRub;
|
||||
if (!p.offer_id || rub == null) return '';
|
||||
return `${p.offer_id} ${Number(rub).toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 拉取全部「已计价」商品详情(分页列表 → 逐个详情;自用规模够用) */
|
||||
async function fetchAllPricedProducts(): Promise<ProductDetail[]> {
|
||||
const pageSize = 100;
|
||||
const details: ProductDetail[] = [];
|
||||
for (let page = 1; page < 50; page++) {
|
||||
const res = await listProducts({ page, page_size: pageSize });
|
||||
for (const item of res.items) {
|
||||
try {
|
||||
const d = await getProduct(item.id);
|
||||
if ((d.pricing as Record<string, unknown> | null)?.calculatedAt) details.push(d);
|
||||
} catch {
|
||||
/* 单个失败跳过,不阻断批量导出 */
|
||||
}
|
||||
}
|
||||
if (page * pageSize >= res.total) break;
|
||||
}
|
||||
return details;
|
||||
}
|
||||
|
||||
function stamp(): string {
|
||||
const d = new Date();
|
||||
const p = (v: number) => String(v).padStart(2, '0');
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
}
|
||||
|
||||
export default function TrialExportPanel({ product }: Props) {
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [comboOpen, setComboOpen] = useState(false);
|
||||
const [comboText, setComboText] = useState('');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const pr = (product.pricing ?? {}) as Record<string, any>;
|
||||
|
||||
const exportCurrent = () => {
|
||||
if (!pr.calculatedAt) {
|
||||
message.warning('尚未计价,请先在「价格试算」完成参数填写');
|
||||
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('缺少货号或计价结果(卢布预留价)');
|
||||
return;
|
||||
}
|
||||
const ok = await copyText(code);
|
||||
ok ? message.success(`已复制:${code}`) : message.error('复制失败,请手动复制');
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Descriptions size="small" column={3} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="计价入库">
|
||||
{pr.calculatedAt ? (
|
||||
<Tag color="green">已入库 {new Date(pr.calculatedAt as string).toLocaleString()}</Tag>
|
||||
) : (
|
||||
<Tag color="orange">未计价</Tag>
|
||||
)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="货号">{product.offer_id || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="俄文标题">
|
||||
<Text ellipsis={{ tooltip: product.name }} style={{ maxWidth: 220 }}>
|
||||
{product.name || '—'}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Space wrap>
|
||||
<Button type="primary" icon={<DownloadOutlined />} onClick={exportCurrent}>
|
||||
导出本商品 CSV
|
||||
</Button>
|
||||
<Button icon={<FileTextOutlined />} loading={batchLoading} onClick={exportBatch}>
|
||||
批量导出 CSV(全部已计价)
|
||||
</Button>
|
||||
<Button icon={<CopyOutlined />} onClick={copyCurrentCombo}>
|
||||
复制本商品组合码
|
||||
</Button>
|
||||
<Button loading={batchLoading} onClick={openBatchCombo}>
|
||||
批量组合码
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||
组合码格式:`货号 预留₽价`(无预留价回退销售价₽),用于 Ozon 后台批量改价;
|
||||
CSV 为 16 列(对齐 v1 工具台),UTF-8 BOM 编码可直接用 Excel 打开。
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title={`上品组合码(${comboText ? comboText.split('\n').length : 0} 条)`}
|
||||
open={comboOpen}
|
||||
onCancel={() => setComboOpen(false)}
|
||||
footer={[
|
||||
<Button
|
||||
key="copy"
|
||||
type="primary"
|
||||
onClick={async () => {
|
||||
const ok = await copyText(comboText);
|
||||
ok ? message.success('已复制') : message.error('复制失败,请手动复制');
|
||||
}}
|
||||
>
|
||||
复制全部
|
||||
</Button>,
|
||||
<Button key="close" onClick={() => setComboOpen(false)}>
|
||||
关闭
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={12} readOnly value={comboText} />
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import { useState } from 'react';
|
||||
import { Col, Collapse, Input, InputNumber, message, Row, Typography } from 'antd';
|
||||
import { CopyOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { copyText } from '@/utils/file';
|
||||
import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => void;
|
||||
}
|
||||
|
||||
/** 包装尺寸 mm → cm 展示 */
|
||||
function dimsCm(product: ProductDetail): { l: number | null; w: number | null; h: number | null } {
|
||||
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
||||
const cv = (v: number | null) => (v == null ? null : Number((v * scale).toFixed(2)));
|
||||
return { l: cv(product.depth ?? null), w: cv(product.width ?? null), h: cv(product.height ?? null) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 01 商品信息:采集结果核对/补录。编辑失焦即落库(父级 PATCH)。
|
||||
* 重量/尺寸是计价与生图上下文的唯一真源(写 product 包装字段)。
|
||||
*/
|
||||
export default function TrialInfoPanel({ product, onSave }: Props) {
|
||||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const [titleZh, setTitleZh] = useState(((raw.title_zh as string) ?? (raw.title as string) ?? '').trim());
|
||||
const [nameRu, setNameRu] = useState(product.name ?? '');
|
||||
const [offerId, setOfferId] = useState(product.offer_id ?? '');
|
||||
const [purchaseUrl, setPurchaseUrl] = useState(
|
||||
(raw.purchase_url as string) ??
|
||||
(['1688', 'taobao'].includes(product.source_platform ?? '') ? (product.source_url ?? '') : ''),
|
||||
);
|
||||
const [desc, setDesc] = useState((raw.desc as string) ?? '');
|
||||
const [weightG, setWeightG] = useState<number | null>(product.weight ?? null);
|
||||
const [dims, setDims] = useState(dimsCm(product));
|
||||
|
||||
const patchRaw = (patch: Record<string, unknown>) => onSave({ raw: { ...raw, ...patch } });
|
||||
const readOnly = (v: unknown) => (v ? String(v) : '—');
|
||||
|
||||
const params = Array.isArray(raw.params) ? (raw.params as Array<{ key: string; value: string }>) : [];
|
||||
|
||||
const openUrl = purchaseUrl?.trim()
|
||||
? `https://${purchaseUrl.trim().replace(/^https?:\/\//, '')}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={12}>
|
||||
<FieldLabel>标题(中文)</FieldLabel>
|
||||
<Input
|
||||
value={titleZh}
|
||||
placeholder="采集原标题可在此整理为中文"
|
||||
onChange={(e) => setTitleZh(e.target.value)}
|
||||
onBlur={() => patchRaw({ title_zh: titleZh.trim() })}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FieldLabel>俄文标题(文案生成可回填)</FieldLabel>
|
||||
<Input
|
||||
value={nameRu}
|
||||
onChange={(e) => setNameRu(e.target.value)}
|
||||
onBlur={() => onSave({ name: nameRu })}
|
||||
/>
|
||||
</Col>
|
||||
</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}>
|
||||
<Collapse
|
||||
ghost
|
||||
items={[
|
||||
{
|
||||
key: 'params',
|
||||
label: <Text strong>规格 / 参数({params.length} 项)</Text>,
|
||||
children: (
|
||||
<div style={{ maxHeight: 260, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', fontSize: 12, borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{params.slice(0, 60).map((p, i) => (
|
||||
<tr key={i} style={{ borderBottom: '1px solid #f0f0f0' }}>
|
||||
<td style={{ padding: '4px 12px 4px 0', color: '#888', whiteSpace: 'nowrap', width: 160 }}>
|
||||
{p.key}
|
||||
</td>
|
||||
<td style={{ padding: '4px 0' }}>{p.value}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ ...fieldRowStyle, marginBottom: 0 }}>
|
||||
<FieldLabel>商品描述(供文案生成与生图上下文)</FieldLabel>
|
||||
<Input.TextArea
|
||||
rows={5}
|
||||
value={desc}
|
||||
onChange={(e) => setDesc(e.target.value)}
|
||||
onBlur={() => patchRaw({ desc })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* 商品试算页(docs/v2.1/trial-page.md):采集后的主工作流。
|
||||
* 01 商品信息 → 02 价格试算 → 03 俄文文案 → 04 图片与AI生图 → 05 入库与导出。
|
||||
* 操作流水线对齐 v1 web 工具台;数据自动落库(products 表)。
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useParams } from 'react-router';
|
||||
import { Card, Menu, Space, Spin, Typography, message } from 'antd';
|
||||
import { getProduct, listAssets, ProductDetail, ProductAsset, updateProduct } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import { STAGE_COLOR, STAGE_LABEL } from '../collection/CollectionPage';
|
||||
import TrialInfoPanel from './TrialInfoPanel';
|
||||
import TrialPricingPanel from './TrialPricingPanel';
|
||||
import TrialSuitePanel from './TrialSuitePanel';
|
||||
import TrialExportPanel from './TrialExportPanel';
|
||||
import CopyPanel from '../product/CopyPanel';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const SECTIONS = [
|
||||
{ id: 'section-info', label: '商品信息' },
|
||||
{ id: 'section-pricing', label: '价格试算' },
|
||||
{ id: 'section-copy', label: '俄文文案' },
|
||||
{ id: 'section-images', label: '图片与AI生图' },
|
||||
{ id: 'section-export', label: '入库与导出' },
|
||||
];
|
||||
|
||||
export default function TrialPage() {
|
||||
const { id } = useParams();
|
||||
const [product, setProduct] = useState<ProductDetail | null>(null);
|
||||
const [assets, setAssets] = useState<ProductAsset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [active, setActive] = useState(SECTIONS[0].id);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [p, a] = await Promise.all([getProduct(id), listAssets(id)]);
|
||||
setProduct(p);
|
||||
setAssets(a);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// 滚动监听:高亮当前区域
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
let current = SECTIONS[0].id;
|
||||
for (const s of SECTIONS) {
|
||||
const el = document.getElementById(s.id);
|
||||
if (el && el.getBoundingClientRect().top <= 90) current = s.id;
|
||||
}
|
||||
setActive(current);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(
|
||||
async (partial: Partial<ProductDetail>) => {
|
||||
if (!id || !product) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const updated = await updateProduct(id, partial);
|
||||
setProduct(updated);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[id, product],
|
||||
);
|
||||
|
||||
if (loading || !product) {
|
||||
return (
|
||||
<div style={{ padding: 80, textAlign: 'center' }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const titleZh = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', gap: 16 }}>
|
||||
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Space>
|
||||
<Title level={4} style={{ margin: 0 }}>
|
||||
{titleZh || product.name || '(未命名商品)'}
|
||||
</Title>
|
||||
<span style={{ color: STAGE_COLOR[product.stage] }}>
|
||||
{STAGE_LABEL[product.stage] || product.stage}
|
||||
</span>
|
||||
</Space>
|
||||
<Text type="secondary">{saving ? '保存中…' : '已自动保存'}</Text>
|
||||
</div>
|
||||
<Space size={16} style={{ marginTop: 4 }}>
|
||||
{product.name && product.name !== titleZh && (
|
||||
<Text type="secondary" ellipsis={{ tooltip: product.name }} style={{ maxWidth: 420 }}>
|
||||
俄文:{product.name}
|
||||
</Text>
|
||||
)}
|
||||
<Link to={`/product/${product.id}`}>进入商品编辑页 →</Link>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* key 随商品切换:面板本地状态(表单/方案/勾选)一次性初始化,避免保存回显互相覆盖 */}
|
||||
<div id="section-info">
|
||||
<Card title="01 商品信息">
|
||||
<TrialInfoPanel key={`info-${product.id}`} product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-pricing">
|
||||
<Card title="02 价格试算">
|
||||
<TrialPricingPanel key={`pricing-${product.id}`} product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-copy">
|
||||
<Card title="03 俄文文案(AI)">
|
||||
<CopyPanel product={product} onSave={save} />
|
||||
</Card>
|
||||
</div>
|
||||
<div id="section-images">
|
||||
<TrialSuitePanel product={product} assets={assets} onRefreshAssets={load} />
|
||||
</div>
|
||||
<div id="section-export">
|
||||
<Card title="05 入库与导出">
|
||||
<TrialExportPanel product={product} />
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧区域导航 */}
|
||||
<div style={{ width: 120, flexShrink: 0 }}>
|
||||
<div style={{ position: 'sticky', top: 80 }}>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[active]}
|
||||
style={{ borderInlineEnd: 0, background: 'transparent' }}
|
||||
items={SECTIONS.map((s) => ({ key: s.id, label: s.label }))}
|
||||
onClick={({ key }) => {
|
||||
document.getElementById(key)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import { useEffect, useState, type CSSProperties } from 'react';
|
||||
import { Alert, Button, Card, Col, InputNumber, Radio, Row, Space, Typography, message } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { ProductDetail } from '@/services/product';
|
||||
import { getFxRate } from '@/services/fx';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import {
|
||||
calculatePricing,
|
||||
logisticsFeeRule,
|
||||
validateDimensions,
|
||||
validateLogisticsLevelCny,
|
||||
validatePriceRange,
|
||||
type LogisticsLevel,
|
||||
type PricingResult,
|
||||
} from '@/pricing/pricing';
|
||||
import FieldLabel, { fieldRowStyle } from '../product/FieldLabel';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
/** v1 web/ozonSeller.html 颜色:完全成本/平台总抽成 text-red-400,销售价 text-secondary */
|
||||
const COLOR_RED = '#f87171';
|
||||
const COLOR_GREEN = '#10b981';
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
onSave: (p: Partial<ProductDetail>) => void;
|
||||
}
|
||||
|
||||
type PricingPayload = Record<string, any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; reserve: number; fxRate: number }>;
|
||||
|
||||
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 } {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 结果指标:可指定价格颜色(v1 完全成本/总抽成红色、销售价绿色) */
|
||||
function Metric({ label, value, rule, color }: { label: string; value: string; rule?: string; color?: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'rgba(0,0,0,0.45)' }}>{label}</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 600, lineHeight: '28px', color: color ?? undefined }}>{value}</div>
|
||||
{rule && <div style={{ fontSize: 11, color: 'rgba(0,0,0,0.35)' }}>{rule}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 销售价格四宫格:人民币/卢布 × 销售价/划线价,价格居中,划线价带删除线 */
|
||||
function SalePriceGrid({
|
||||
cnySelling,
|
||||
cnyLine,
|
||||
rubSelling,
|
||||
rubLine,
|
||||
reserve,
|
||||
fxRate,
|
||||
cnyGap,
|
||||
rubGap,
|
||||
}: {
|
||||
cnySelling: string;
|
||||
cnyLine: string;
|
||||
rubSelling: string;
|
||||
rubLine: string;
|
||||
reserve: number;
|
||||
fxRate: number;
|
||||
cnyGap: number | null;
|
||||
rubGap: number | null;
|
||||
}) {
|
||||
const cellStyle: CSSProperties = {
|
||||
textAlign: 'center',
|
||||
background: '#fff',
|
||||
borderRadius: 8,
|
||||
padding: '12px 8px',
|
||||
};
|
||||
const priceStyle: CSSProperties = {
|
||||
fontSize: 20,
|
||||
fontWeight: 700,
|
||||
color: COLOR_GREEN,
|
||||
lineHeight: '30px',
|
||||
};
|
||||
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' };
|
||||
return (
|
||||
<div style={{ borderTop: '1px dashed #e5e7eb', paddingTop: 14, marginTop: 14 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
|
||||
<div style={cellStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-销售价格</div>
|
||||
<div style={priceStyle}>{cnySelling}</div>
|
||||
<div style={noteStyle}>销售价(现价)</div>
|
||||
</div>
|
||||
<div style={cellStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-销售价格</div>
|
||||
<div style={priceStyle}>{rubSelling}</div>
|
||||
<div style={noteStyle}>
|
||||
按 1 ¥ = {fxRate ? fxRate.toFixed(2) : '--'} ₽
|
||||
</div>
|
||||
</div>
|
||||
<div style={cellStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
|
||||
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
|
||||
<div style={noteStyle}>
|
||||
预留 {reserve}% 后{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={cellStyle}>
|
||||
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
|
||||
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
|
||||
<div style={noteStyle}>
|
||||
预留 {reserve}% 后{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
|
||||
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
|
||||
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
|
||||
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
|
||||
* 尺寸不合规时不落计价结果;预留折扣空间 ≥100% 时划线价无法计算(显示 --,只落销售价)。
|
||||
*/
|
||||
export default function TrialPricingPanel({ product, onSave }: Props) {
|
||||
const pricing = (product.pricing ?? {}) as PricingPayload;
|
||||
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
|
||||
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
|
||||
const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3);
|
||||
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
|
||||
const [reserve, setReserve] = useState(pricing.discountReserve ?? 50);
|
||||
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
|
||||
const [fxSource, setFxSource] = useState('');
|
||||
|
||||
// 币种固定人民币(历史数据默认 RUB 时纠正一次)
|
||||
useEffect(() => {
|
||||
if (product.currency_code !== 'CNY') onSave({ currency_code: 'CNY' });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 汇率:无快照时拉取并立即计价入库(试算页首次打开即完成一次入库)
|
||||
useEffect(() => {
|
||||
if (!fxRate) {
|
||||
getFxRate()
|
||||
.then((r) => {
|
||||
setFxRate(r.rate);
|
||||
setFxSource(r.source);
|
||||
recalc({ fxRate: r.rate });
|
||||
})
|
||||
.catch((e) => message.error(apiErrorMessage(e)));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const refreshFx = async () => {
|
||||
try {
|
||||
const r = await getFxRate();
|
||||
setFxRate(r.rate);
|
||||
setFxSource(r.source);
|
||||
recalc({ fxRate: r.rate });
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const compute = (patch: PricingState = {}): PricingResult =>
|
||||
calculatePricing({
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
logisticsLevel: patch.level ?? level,
|
||||
weightG: weightGrams(product),
|
||||
dims: dimsCm(product),
|
||||
tdPrice: patch.tdPrice ?? tdPrice,
|
||||
discountReserve: patch.reserve ?? reserve,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
});
|
||||
|
||||
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值) */
|
||||
const recalc = (patch: PricingState = {}) => {
|
||||
const p = {
|
||||
purchasePrice: patch.purchasePrice ?? purchasePrice,
|
||||
profitRate: patch.profitRate ?? profitRate,
|
||||
tdPrice: patch.tdPrice ?? tdPrice,
|
||||
level: patch.level ?? level,
|
||||
reserve: patch.reserve ?? reserve,
|
||||
fxRate: patch.fxRate ?? fxRate,
|
||||
};
|
||||
const dims = dimsCm(product);
|
||||
const dimError = validateDimensions(weightGrams(product), dims, p.level);
|
||||
const r = dimError ? null : calculatePricing({
|
||||
purchasePrice: p.purchasePrice,
|
||||
profitRate: p.profitRate,
|
||||
logisticsLevel: p.level,
|
||||
weightG: weightGrams(product),
|
||||
dims,
|
||||
tdPrice: p.tdPrice,
|
||||
discountReserve: p.reserve,
|
||||
fxRate: p.fxRate,
|
||||
});
|
||||
// 预留折扣空间 ≥100% 时划线价无数学意义(除数 ≤0):只保存销售价,并清空划线价字段
|
||||
const lineValid = p.reserve < 100;
|
||||
const lineFields = r && lineValid
|
||||
? {
|
||||
logisticsFee: r.logisticsFee,
|
||||
receivedPrice: r.receivedPrice,
|
||||
profitPrice: r.profitPrice,
|
||||
commission: r.commission,
|
||||
fullCommission: r.fullCommission,
|
||||
totalCost: r.totalCost,
|
||||
sellingPriceCny: r.sellingPriceCny,
|
||||
reservedPriceCny: r.reservedPriceCny,
|
||||
sellingPriceRub: r.sellingPriceRub,
|
||||
reservedPriceRub: r.reservedPriceRub,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}
|
||||
: r
|
||||
? {
|
||||
logisticsFee: r.logisticsFee,
|
||||
receivedPrice: r.receivedPrice,
|
||||
profitPrice: r.profitPrice,
|
||||
commission: r.commission,
|
||||
fullCommission: r.fullCommission,
|
||||
totalCost: r.totalCost,
|
||||
sellingPriceCny: r.sellingPriceCny,
|
||||
sellingPriceRub: r.sellingPriceRub,
|
||||
reservedPriceCny: null,
|
||||
reservedPriceRub: null,
|
||||
calculatedAt: new Date().toISOString(),
|
||||
}
|
||||
: {};
|
||||
onSave({
|
||||
pricing: {
|
||||
...pricing,
|
||||
purchasePrice: p.purchasePrice,
|
||||
profitRate: p.profitRate,
|
||||
tdPrice: p.tdPrice,
|
||||
logisticsLevel: p.level,
|
||||
discountReserve: p.reserve,
|
||||
fxRate: p.fxRate,
|
||||
weightG: weightGrams(product),
|
||||
dims,
|
||||
...lineFields,
|
||||
},
|
||||
fx_rate: p.fxRate,
|
||||
...(r && lineValid
|
||||
? { price: r.sellingPriceCny, old_price: r.reservedPriceCny }
|
||||
: r
|
||||
? { price: r.sellingPriceCny, old_price: null }
|
||||
: {}),
|
||||
currency_code: 'CNY',
|
||||
});
|
||||
};
|
||||
|
||||
const dimError = fxRate ? validateDimensions(weightGrams(product), dimsCm(product), level) : '';
|
||||
const preview = fxRate && !dimError ? compute() : null;
|
||||
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
|
||||
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
|
||||
const lineValid = reserve < 100;
|
||||
const discountGap = preview && lineValid ? preview.reservedPriceCny - preview.sellingPriceCny : 0;
|
||||
|
||||
const num = (v: number | undefined | null, digits = 2) => (v == null ? '--' : v.toFixed(digits));
|
||||
|
||||
return (
|
||||
<Row gutter={24}>
|
||||
{/* ── 左:操作项(进货价 → 物流等级) ── */}
|
||||
<Col span={10}>
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={12}>
|
||||
<FieldLabel>进货价 ¥</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={purchasePrice}
|
||||
onChange={(v) => {
|
||||
const n = v ?? 0;
|
||||
setPurchasePrice(n);
|
||||
recalc({ purchasePrice: n });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FieldLabel>净利率 %</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={profitRate}
|
||||
onChange={(v) => {
|
||||
const n = v ?? 0;
|
||||
setProfitRate(n);
|
||||
recalc({ profitRate: n });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={12}>
|
||||
<FieldLabel>贴单费用 ¥</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={tdPrice}
|
||||
onChange={(v) => {
|
||||
const n = v ?? 0;
|
||||
setTdPrice(n);
|
||||
recalc({ tdPrice: n });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<FieldLabel>预留折扣空间 %</FieldLabel>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
max={500}
|
||||
step={5}
|
||||
value={reserve}
|
||||
onChange={(v) => {
|
||||
const n = v ?? 0;
|
||||
setReserve(n);
|
||||
recalc({ reserve: n });
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={16} style={fieldRowStyle}>
|
||||
<Col span={13}>
|
||||
<FieldLabel>
|
||||
汇率 ¥→₽{' '}
|
||||
{fxSource && (
|
||||
<Text type="secondary" style={{ fontSize: 11, fontWeight: 400 }}>
|
||||
({fxSource})
|
||||
</Text>
|
||||
)}
|
||||
</FieldLabel>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
value={fxRate || undefined}
|
||||
onChange={(v) => {
|
||||
const n = v ?? 0;
|
||||
setFxRate(n);
|
||||
recalc({ fxRate: n });
|
||||
}}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={refreshFx} title="刷新汇率" />
|
||||
</Space.Compact>
|
||||
</Col>
|
||||
<Col span={11}>
|
||||
<FieldLabel>重量 / 尺寸</FieldLabel>
|
||||
<Text type="secondary" style={{ fontSize: 12, lineHeight: '32px' }}>
|
||||
取自上方包装信息
|
||||
</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div style={fieldRowStyle}>
|
||||
<FieldLabel>物流等级</FieldLabel>
|
||||
<Radio.Group
|
||||
block
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
value={level}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value as LogisticsLevel;
|
||||
setLevel(v);
|
||||
recalc({ level: v });
|
||||
}}
|
||||
options={[
|
||||
{ value: 'low', label: '低 (low)' },
|
||||
{ value: 'high', label: '高 (high)' },
|
||||
{ value: 'high2', label: 'Premium (high2)' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
|
||||
参数变化自动计价并入库;尺寸不合规时不落计价结果。重量/尺寸在「商品信息」区块维护。
|
||||
</Text>
|
||||
</Col>
|
||||
|
||||
{/* ── 右:展示项(物流费 → 销售价) ── */}
|
||||
<Col span={14}>
|
||||
{(levelHint || rangeHint) && (
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
{levelHint && <Alert type="warning" showIcon message={levelHint} style={{ marginBottom: 8 }} />}
|
||||
{rangeHint && <Alert type="warning" showIcon message={rangeHint} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card size="small" style={{ background: '#fafafa' }}>
|
||||
{/* 第一行:物流费 / 平台佣金 / 提现费及其他 */}
|
||||
<Row gutter={[16, 20]}>
|
||||
<Col span={8}>
|
||||
<Metric
|
||||
label="物流费"
|
||||
value={preview ? `¥ ${num(preview.logisticsFee)}` : '--'}
|
||||
rule={preview ? `(${logisticsFeeRule(weightGrams(product), level, tdPrice)})` : ''}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Metric
|
||||
label="平台佣金"
|
||||
value={preview ? `¥ ${num(preview.commission)}` : '--'}
|
||||
rule={level === 'low' ? '(销售价 × 12%)' : '(销售价 × 18%)'}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Metric
|
||||
label="提现费及其他"
|
||||
value={preview ? `¥ ${num(preview.fullCommission - preview.commission)}` : '--'}
|
||||
rule="(平台总抽成 − 平台佣金,销售价 × 3.5%)"
|
||||
color={COLOR_RED}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第二行:完全成本 / 净利润 / 实收价 */}
|
||||
<Row gutter={[16, 20]} style={{ marginTop: 4 }}>
|
||||
<Col span={8}>
|
||||
<Metric
|
||||
label="完全成本"
|
||||
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
|
||||
rule="(进货价 + 物流费 + 平台总抽成)"
|
||||
color={COLOR_RED}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* 第三、四行:人民币/卢布 销售价 + 划线价 四宫格 */}
|
||||
<SalePriceGrid
|
||||
cnySelling={preview ? `¥ ${num(preview.sellingPriceCny)}` : '--'}
|
||||
cnyLine={preview && lineValid ? `¥ ${num(preview.reservedPriceCny)}` : '--'}
|
||||
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
|
||||
rubLine={preview && lineValid ? `₽ ${num(preview.reservedPriceRub, 0)}` : '--'}
|
||||
reserve={reserve}
|
||||
fxRate={fxRate}
|
||||
cnyGap={preview && lineValid ? discountGap : null}
|
||||
rubGap={preview && lineValid ? preview.reservedPriceRub - preview.sellingPriceRub : null}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,866 @@
|
||||
/**
|
||||
* 04 图片与 AI 生图(docs/v2.1/image-suite.md):
|
||||
* 采集图片(分组勾选/上传/单张AI生图) + 出图方案(AI规划/风格/要求/模型/一键生成) + 生成结果(导出 ZIP)。
|
||||
* 交互对齐 image-suite-studio 面板 02/03/04 区块;套图服务端接口 Phase B 提供。
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Alert, Button, Card, Checkbox, Empty, Image, Input, InputNumber, message, Modal, Popover, Progress,
|
||||
Radio, Row, Segmented, Select, Space, Tag, Typography, Upload,
|
||||
} from 'antd';
|
||||
import { DownloadOutlined, SettingOutlined, ThunderboltOutlined, UploadOutlined } from '@ant-design/icons';
|
||||
import { ProductAsset, ProductDetail } from '@/services/product';
|
||||
import { apiErrorMessage } from '@/services/api';
|
||||
import {
|
||||
DEFAULT_IMAGE_MODEL, DEFAULT_PLAN, DEFAULT_WATERMARK, IMAGE_MODEL_OPTIONS,
|
||||
STYLE_SET_OPTIONS, SuiteInfo, SuiteTextPayload, WatermarkPayload,
|
||||
downloadSuiteZip, exportImages, generateSuite, getSuite, planSuite, uploadProductAsset,
|
||||
type PlanItem,
|
||||
} from '@/services/suite';
|
||||
import { cleanFilename, downloadBlob } from '@/utils/file';
|
||||
import AiImageGenModal from './AiImageGenModal';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const GROUP_LABELS: Record<string, string> = {
|
||||
main: '主图',
|
||||
sku: 'SKU 图',
|
||||
detail: '详情图',
|
||||
generated: '生成图',
|
||||
upload: '手动上传',
|
||||
param: '参数图',
|
||||
video: '视频',
|
||||
};
|
||||
const DISPLAY_GROUPS = ['main', 'sku', 'detail', 'generated', 'upload'];
|
||||
const WATERMARK_STORAGE_KEY = 'trialWatermark';
|
||||
|
||||
interface Props {
|
||||
product: ProductDetail;
|
||||
assets: ProductAsset[];
|
||||
onRefreshAssets: () => void;
|
||||
}
|
||||
|
||||
/** 商品信息 → 规划/生成的文本素材(包装字段覆盖回参数表,对齐 image-suite-studio editedTexts) */
|
||||
function buildSuiteTexts(product: ProductDetail): SuiteTextPayload[] {
|
||||
const raw = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const texts: SuiteTextPayload[] = [];
|
||||
const title = ((raw.title_zh as string) ?? (raw.title as string) ?? '').trim();
|
||||
if (title) texts.push({ kind: 'title', content: title });
|
||||
if (raw.price) texts.push({ kind: 'price', content: String(raw.price) });
|
||||
(['brand', 'sales', 'shop'] as const).forEach((k) => {
|
||||
const v = raw[k];
|
||||
if (v) texts.push({ kind: k, content: String(v) });
|
||||
});
|
||||
const sellingPoint = (raw.selling_point as string) ?? (raw.sellingPoints as string);
|
||||
if (sellingPoint) texts.push({ kind: 'selling_point', content: sellingPoint });
|
||||
|
||||
let pairs = Array.isArray(raw.params) ? [...(raw.params as Array<{ key: string; value: string }>)] : [];
|
||||
pairs = pairs.filter((p) => !/尺寸|长宽高|重量/i.test(p.key));
|
||||
if (product.weight != null && product.weight > 0) pairs.push({ key: '重量', value: `${product.weight}g` });
|
||||
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
|
||||
if (product.depth != null && product.width != null && product.height != null) {
|
||||
const d = { l: product.depth * scale, w: product.width * scale, h: product.height * scale };
|
||||
pairs.push({ key: '产品尺寸', value: `${d.l}×${d.w}×${d.h}` });
|
||||
}
|
||||
if (pairs.length) texts.push({ kind: 'params', content: '', pairs });
|
||||
|
||||
if (raw.desc) texts.push({ kind: 'desc', content: String(raw.desc) });
|
||||
return texts;
|
||||
}
|
||||
|
||||
function loadWatermark(): WatermarkPayload {
|
||||
try {
|
||||
const s = localStorage.getItem(WATERMARK_STORAGE_KEY);
|
||||
if (s) return { ...DEFAULT_WATERMARK, ...(JSON.parse(s) as WatermarkPayload) };
|
||||
} catch { /* 忽略坏数据 */ }
|
||||
return DEFAULT_WATERMARK;
|
||||
}
|
||||
|
||||
export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Props) {
|
||||
// 素材选择(默认全选主图 + SKU 图,SKU 规格名供 AI 规划 variant 绑定)
|
||||
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(
|
||||
() => new Set(assets.filter((a) => ['main', 'sku'].includes(a.group_key) && a.type !== 'video').map((a) => a.id)),
|
||||
);
|
||||
/** 上传成功待自动勾选的 asset id */
|
||||
const pendingSelectRef = useRef<Set<string>>(new Set());
|
||||
const [uploadingCount, setUploadingCount] = useState(0);
|
||||
|
||||
// 出图方案
|
||||
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map((p) => ({ ...p })));
|
||||
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
|
||||
const [planSummary, setPlanSummary] = useState('');
|
||||
const [planning, setPlanning] = useState(false);
|
||||
const [planThenGenerate, setPlanThenGenerate] = useState(false);
|
||||
const [styleSet, setStyleSet] = useState(1);
|
||||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||||
const [requirements, setRequirements] = useState('');
|
||||
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
|
||||
const [watermark, setWatermark] = useState<WatermarkPayload>(loadWatermark);
|
||||
|
||||
// 生成
|
||||
const [suite, setSuite] = useState<SuiteInfo | null>(null);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [exportingZip, setExportingZip] = useState(false);
|
||||
const [exportingImages, setExportingImages] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
/** 规划请求序号:重置后丢弃迟到的过期响应 */
|
||||
const planSeqRef = useRef(0);
|
||||
|
||||
// 单张 AI 生图弹窗
|
||||
const [genModal, setGenModal] = useState<{ url: string; name: string } | null>(null);
|
||||
|
||||
const imgs = useMemo(() => assets.filter((a) => a.type !== 'video'), [assets]);
|
||||
const groupImages = (g: string) => imgs.filter((a) => a.group_key === g);
|
||||
const assetUrl = (a: ProductAsset) => a.stored_url || a.source_url;
|
||||
const rawObj = (product.raw ?? {}) as Record<string, unknown>;
|
||||
const productTitle = ((rawObj.title_zh as string) || (rawObj.title as string) || product.name || '').trim();
|
||||
|
||||
// 上传完成的素材自动勾选(素材列表刷新后合并)
|
||||
useEffect(() => {
|
||||
if (pendingSelectRef.current.size === 0) return;
|
||||
const next = new Set(selectedKeys);
|
||||
let touched = false;
|
||||
pendingSelectRef.current.forEach((id) => {
|
||||
if (assets.some((a) => a.id === id) && !next.has(id)) {
|
||||
next.add(id);
|
||||
touched = true;
|
||||
}
|
||||
});
|
||||
pendingSelectRef.current.clear();
|
||||
if (touched) setSelectedKeys(next);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assets]);
|
||||
|
||||
// 组件卸载停止轮询
|
||||
useEffect(() => () => {
|
||||
if (pollRef.current) clearInterval(pollRef.current);
|
||||
}, []);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleKey = (key: string) => {
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGroup = (g: string, on: boolean) => {
|
||||
setSelectedKeys((prev) => {
|
||||
const next = new Set(prev);
|
||||
groupImages(g).forEach((a) => (on ? next.add(a.id) : next.delete(a.id)));
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const uploadButton = (
|
||||
<Upload
|
||||
accept="image/*"
|
||||
multiple
|
||||
showUploadList={false}
|
||||
customRequest={async ({ file, onSuccess, onError }) => {
|
||||
setUploadingCount((c) => c + 1);
|
||||
try {
|
||||
const r = await uploadProductAsset(product.id, file as File);
|
||||
pendingSelectRef.current.add(r.asset_id);
|
||||
onSuccess?.(r);
|
||||
} catch (e) {
|
||||
message.error(`${(file as File).name}:${apiErrorMessage(e)}`);
|
||||
onError?.(e as Error);
|
||||
} finally {
|
||||
setUploadingCount((c) => c - 1);
|
||||
onRefreshAssets();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />} size="small" loading={uploadingCount > 0}>
|
||||
上传图片
|
||||
</Button>
|
||||
</Upload>
|
||||
);
|
||||
|
||||
// ── 下载采集图片(勾选图打包 ZIP) ─────────────────────────────
|
||||
const handleExportImages = async () => {
|
||||
const selected = imgs.filter((a) => selectedKeys.has(a.id));
|
||||
if (selected.length === 0) {
|
||||
message.warning('请先勾选要下载的图片');
|
||||
return;
|
||||
}
|
||||
setExportingImages(true);
|
||||
try {
|
||||
const blob = await exportImages({
|
||||
title: productTitle,
|
||||
images: selected.map((a) => ({
|
||||
url: assetUrl(a),
|
||||
groupName: GROUP_LABELS[a.group_key] ?? a.group_key,
|
||||
variantName: a.variant_name,
|
||||
key: a.id,
|
||||
})),
|
||||
});
|
||||
downloadBlob(blob, `${cleanFilename(productTitle) || '采集图片'}.zip`);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setExportingImages(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ── AI 智能规划 ─────────────────────────────────────────────
|
||||
const currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? '';
|
||||
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
|
||||
|
||||
const pollSuite = (suiteId: string) => {
|
||||
stopPolling();
|
||||
const startedAt = Date.now();
|
||||
let failCount = 0;
|
||||
let timeoutWarned = false;
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const s = await getSuite(suiteId);
|
||||
setSuite(s);
|
||||
failCount = 0;
|
||||
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
|
||||
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
|
||||
if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) {
|
||||
timeoutWarned = true;
|
||||
message.warning('任务耗时异常,可能已中断(如服务端重启),可稍后观察或重新生成');
|
||||
}
|
||||
if (['done', 'partial', 'failed'].includes(s.status)) {
|
||||
stopPolling();
|
||||
setGenerating(false);
|
||||
if (s.status === 'partial') message.warning(s.error || '部分生成失败,可重试或更换风格重新生成');
|
||||
if (s.status === 'failed') message.error(s.error || '生成失败');
|
||||
// Phase B 起服务端会把生成图回写素材(generated 组)
|
||||
if (s.status !== 'failed') onRefreshAssets();
|
||||
}
|
||||
} catch {
|
||||
failCount += 1;
|
||||
if (failCount >= 3) {
|
||||
stopPolling();
|
||||
setGenerating(false);
|
||||
message.error('连续查询生成进度失败,已停止跟踪;若服务端刚重启,请重新生成');
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
const startGenerate = async (planOverride?: PlanItem[]): Promise<void> => {
|
||||
if (selectedKeys.size === 0) {
|
||||
message.warning('请先在采集图片区勾选参考底图');
|
||||
return;
|
||||
}
|
||||
const activePlan = (planOverride ?? plan).filter((p) => p.count > 0);
|
||||
if (activePlan.length === 0) {
|
||||
message.warning('出图方案的张数都是 0');
|
||||
return;
|
||||
}
|
||||
setGenerating(true);
|
||||
setSuite(null);
|
||||
try {
|
||||
const { suite_id } = await generateSuite({
|
||||
product_id: product.id,
|
||||
texts: buildSuiteTexts(product),
|
||||
images: imgs
|
||||
.filter((a) => selectedKeys.has(a.id))
|
||||
.map((a) => ({ url: assetUrl(a), group_key: a.group_key, variant_name: a.variant_name })),
|
||||
style_set: styleSet,
|
||||
style_prompt: currentStylePrompt || null,
|
||||
requirements: requirements.trim() || null,
|
||||
plan: activePlan,
|
||||
platform: 'ozon',
|
||||
model,
|
||||
// 水印开启才下发,关闭时不带(服务端按无水印处理)
|
||||
watermark: watermark.enabled ? watermark : undefined,
|
||||
});
|
||||
pollSuite(suite_id);
|
||||
} catch (e) {
|
||||
setGenerating(false);
|
||||
message.error(apiErrorMessage(e));
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlan = async () => {
|
||||
const seq = ++planSeqRef.current;
|
||||
setPlanning(true);
|
||||
try {
|
||||
const skuVariants = Array.from(
|
||||
new Set(assets.filter((a) => a.group_key === 'sku' && a.variant_name).map((a) => a.variant_name!)),
|
||||
);
|
||||
const stats: Record<string, number> = {};
|
||||
imgs.forEach((a) => {
|
||||
stats[a.group_key] = (stats[a.group_key] ?? 0) + 1;
|
||||
});
|
||||
const data = await planSuite({
|
||||
product_id: product.id,
|
||||
texts: buildSuiteTexts(product),
|
||||
sku_variants: skuVariants,
|
||||
image_stats: stats,
|
||||
platform: 'ozon',
|
||||
requirements: requirements.trim() || null,
|
||||
});
|
||||
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
|
||||
setPlan(data.items);
|
||||
setPlanSource('ai');
|
||||
setPlanSummary(data.summary);
|
||||
if (planThenGenerate) {
|
||||
await startGenerate(data.items);
|
||||
} else {
|
||||
const total = data.items.reduce((s, i) => s + i.count, 0);
|
||||
message.info(`AI 方案已生成:${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`);
|
||||
}
|
||||
} catch (e) {
|
||||
if (seq !== planSeqRef.current) return;
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
if (seq === planSeqRef.current) setPlanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGenerate = () => {
|
||||
if (selectedKeys.size === 0) {
|
||||
message.warning('请先在采集图片区勾选参考底图');
|
||||
return;
|
||||
}
|
||||
if (totalPlanned === 0) {
|
||||
message.warning('出图方案的张数都是 0');
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: '生成电商套图',
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
目标平台「Ozon」(俄文文案 · 3:4 图片),模型「{model}」,风格「
|
||||
{STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考底图{' '}
|
||||
{selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
okText: '开始生成',
|
||||
cancelText: '取消',
|
||||
onOk: () => startGenerate(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleExportZip = async () => {
|
||||
if (!suite) return;
|
||||
setExportingZip(true);
|
||||
try {
|
||||
const blob = await downloadSuiteZip(suite.id);
|
||||
downloadBlob(blob, `${cleanFilename(productTitle) || '套图'}.zip`);
|
||||
} catch (e) {
|
||||
message.error(apiErrorMessage(e));
|
||||
} finally {
|
||||
setExportingZip(false);
|
||||
}
|
||||
};
|
||||
|
||||
const setPlanCount = (idx: number, count: number) => {
|
||||
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count } : p)));
|
||||
};
|
||||
const togglePlanRow = (idx: number) => {
|
||||
setPlan((prev) => prev.map((p, i) => (i === idx ? { ...p, count: p.count > 0 ? 0 : 1 } : p)));
|
||||
};
|
||||
const planAllEnabled = plan.every((p) => p.count >= 1);
|
||||
const togglePlanAll = (on: boolean) => {
|
||||
setPlan((prev) => prev.map((p) => (on ? { ...p, count: Math.max(p.count, 1) } : { ...p, count: 0 })));
|
||||
};
|
||||
|
||||
const doneCount = suite?.images.filter((i) => i.status === 'ok').length ?? 0;
|
||||
const suiteTotal = suite?.total ?? totalPlanned;
|
||||
|
||||
const patchWatermark = (patch: Partial<WatermarkPayload>) => {
|
||||
setWatermark((prev) => {
|
||||
const next = { ...prev, ...patch };
|
||||
try {
|
||||
localStorage.setItem(WATERMARK_STORAGE_KEY, JSON.stringify(next));
|
||||
} catch { /* 忽略存储失败 */ }
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const watermarkPopup = (
|
||||
<div style={{ width: 280 }}>
|
||||
<Checkbox
|
||||
checked={watermark.enabled}
|
||||
onChange={(e) => patchWatermark({ enabled: e.target.checked })}
|
||||
>
|
||||
生成图加水印
|
||||
</Checkbox>
|
||||
{watermark.enabled && (
|
||||
<>
|
||||
<div style={{ margin: '10px 0 6px' }}>
|
||||
<Radio.Group
|
||||
size="small"
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
value={watermark.type}
|
||||
onChange={(e) => patchWatermark({ type: e.target.value })}
|
||||
options={[
|
||||
{ value: 'image', label: '图片徽章' },
|
||||
{ value: 'text', label: '文字' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{watermark.type === 'text' && (
|
||||
<Input
|
||||
size="small"
|
||||
value={watermark.text}
|
||||
placeholder="水印文字"
|
||||
onChange={(e) => patchWatermark({ text: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
不透明度(%)
|
||||
</Text>
|
||||
<InputNumber
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
min={1}
|
||||
max={100}
|
||||
step={5}
|
||||
value={watermark.opacity}
|
||||
onChange={(v) => patchWatermark({ opacity: v ?? 30 })}
|
||||
/>
|
||||
</div>
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 6 }}>
|
||||
位置固定右下角;重新生成后生效,已生成的图不变
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectableCount = imgs.length;
|
||||
const displayGroups = DISPLAY_GROUPS.filter((g) => groupImages(g).length > 0);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
{/* ── 采集图片 ── */}
|
||||
<Card
|
||||
size="small"
|
||||
title={`采集图片(已选 ${selectedKeys.size} / ${selectableCount})`}
|
||||
extra={
|
||||
<Space>
|
||||
{uploadButton}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<DownloadOutlined />}
|
||||
loading={exportingImages}
|
||||
onClick={handleExportImages}
|
||||
>
|
||||
下载采集图片
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{displayGroups.length === 0 ? (
|
||||
<Empty description="暂无素材,可点击右上角「上传图片」补充参考底图" />
|
||||
) : (
|
||||
<Image.PreviewGroup>
|
||||
{displayGroups.map((g) => (
|
||||
<div key={g} style={{ marginBottom: 14 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Text strong>
|
||||
{GROUP_LABELS[g] ?? g}
|
||||
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 6 }}>
|
||||
{groupImages(g).length}
|
||||
</Text>
|
||||
</Text>
|
||||
<a
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => {
|
||||
const all = groupImages(g).every((a) => selectedKeys.has(a.id));
|
||||
toggleGroup(g, !all);
|
||||
}}
|
||||
>
|
||||
{groupImages(g).every((a) => selectedKeys.has(a.id)) ? '取消全选' : '全选'}
|
||||
</a>
|
||||
{g === 'main' && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
勾选的图片作为生图参考底图
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{groupImages(g).map((a) => {
|
||||
const on = selectedKeys.has(a.id);
|
||||
return (
|
||||
<div key={a.id} style={{ width: 112, position: 'relative' }}>
|
||||
<div
|
||||
style={{
|
||||
border: `2px solid ${on ? '#1677ff' : 'transparent'}`,
|
||||
borderRadius: 8,
|
||||
padding: 2,
|
||||
background: on ? 'rgba(22,119,255,0.06)' : undefined,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={assetUrl(a)}
|
||||
width={100}
|
||||
height={100}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
fallback="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='100' height='100'><rect width='100' height='100' fill='%23eee'/><text x='22' y='52' font-size='11' fill='%23999'>无预览</text></svg>"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
title="勾选为参考底图"
|
||||
onClick={() => toggleKey(a.id)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 6,
|
||||
left: 6,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: '50%',
|
||||
background: on ? '#1677ff' : 'rgba(255,255,255,0.9)',
|
||||
color: on ? '#fff' : '#bbb',
|
||||
border: '1px solid #d9d9d9',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
zIndex: 1,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
{a.variant_name && (
|
||||
<Tag
|
||||
style={{ position: 'absolute', top: 6, right: 6, margin: 0, zIndex: 1 }}
|
||||
>
|
||||
{a.variant_name}
|
||||
</Tag>
|
||||
)}
|
||||
{a.status !== 'uploaded' && (
|
||||
<Tag
|
||||
color={a.status === 'failed' ? 'red' : 'default'}
|
||||
style={{ position: 'absolute', bottom: 40, left: 6, margin: 0, zIndex: 1 }}
|
||||
>
|
||||
{a.status === 'failed' ? '转存失败' : '转存中'}
|
||||
</Tag>
|
||||
)}
|
||||
<Button
|
||||
block
|
||||
size="small"
|
||||
style={{ marginTop: 4 }}
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={() => setGenModal({ url: assetUrl(a), name: a.variant_name || a.id })}
|
||||
>
|
||||
AI 生图
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Image.PreviewGroup>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* ── 出图方案 ── */}
|
||||
<Card
|
||||
size="small"
|
||||
title={`出图方案(共 ${totalPlanned} 张)`}
|
||||
extra={
|
||||
<Space>
|
||||
{planSource === 'ai' && <Tag color="purple">AI 方案</Tag>}
|
||||
<Checkbox
|
||||
checked={planThenGenerate}
|
||||
onChange={(e) => setPlanThenGenerate(e.target.checked)}
|
||||
title="勾选后,AI 规划完成将自动开始生成"
|
||||
>
|
||||
规划并生成
|
||||
</Checkbox>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
icon={<ThunderboltOutlined />}
|
||||
loading={planning}
|
||||
onClick={handlePlan}
|
||||
>
|
||||
AI 智能规划
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
{plan.map((p, idx) => (
|
||||
<div
|
||||
key={`${p.kind}-${idx}`}
|
||||
onClick={() => togglePlanRow(idx)}
|
||||
title="点击勾选/取消该方案(数量用右侧加减调整)"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: '6px 8px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
opacity: p.count === 0 ? 0.45 : 1,
|
||||
background: p.count === 0 ? 'transparent' : 'rgba(22,119,255,0.03)',
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Space size={6} wrap>
|
||||
<Text strong={p.count > 0}>{p.title}</Text>
|
||||
{p.variant_name && <Tag color="purple">{p.variant_name}</Tag>}
|
||||
{p.detail && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: p.detail }}>
|
||||
{p.detail}
|
||||
</Text>
|
||||
)}
|
||||
{p.prompt_hint && (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>🎯 {p.prompt_hint}</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<InputNumber
|
||||
size="small"
|
||||
min={0}
|
||||
max={5}
|
||||
value={p.count}
|
||||
onChange={(v) => setPlanCount(idx, v ?? 0)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, margin: '8px 0' }}>
|
||||
<Checkbox
|
||||
checked={planAllEnabled}
|
||||
onChange={(e) => togglePlanAll(e.target.checked)}
|
||||
title="勾选:全部方案至少 1 张(>1 张保留原数量);取消:全部改为 0,方便单独勾选一种方案"
|
||||
>
|
||||
全部方案
|
||||
</Checkbox>
|
||||
{planSource === 'ai' && (
|
||||
<a
|
||||
style={{ fontSize: 12 }}
|
||||
onClick={() => {
|
||||
setPlan(DEFAULT_PLAN.map((p) => ({ ...p })));
|
||||
setPlanSource('default');
|
||||
setPlanSummary('');
|
||||
}}
|
||||
>
|
||||
恢复默认方案
|
||||
</a>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12, flex: 1 }} ellipsis={{ tooltip: planSummary }}>
|
||||
{planSummary || '方案与张数由规划器按商品信息自动决定,可手动微调,0 即不生成'}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid #f0f0f0', paddingTop: 12, marginTop: 4 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Text style={{ width: 70 }}>视觉风格</Text>
|
||||
<Segmented
|
||||
value={styleSet}
|
||||
onChange={(v) => setStyleSet(v as number)}
|
||||
options={STYLE_SET_OPTIONS.map((s) => ({ value: s.value, label: s.label }))}
|
||||
/>
|
||||
<Popover trigger="click" placement="bottomRight" content={watermarkPopup} title="水印设置">
|
||||
<Button size="small" icon={<SettingOutlined />}>
|
||||
水印
|
||||
</Button>
|
||||
</Popover>
|
||||
</div>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
风格提示词(可编辑,直接决定生成画面风格)
|
||||
{stylePrompts[styleSet] !== undefined && (
|
||||
<a
|
||||
style={{ marginLeft: 8 }}
|
||||
onClick={() =>
|
||||
setStylePrompts((p) => {
|
||||
const n = { ...p };
|
||||
delete n[styleSet];
|
||||
return n;
|
||||
})
|
||||
}
|
||||
>
|
||||
恢复默认
|
||||
</a>
|
||||
)}
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={currentStylePrompt}
|
||||
onChange={(e) => setStylePrompts((p) => ({ ...p, [styleSet]: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
生图要求(优先级最高,强制约束,会覆盖其他设定)
|
||||
</Text>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={requirements}
|
||||
onChange={(e) => setRequirements(e.target.value)}
|
||||
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
|
||||
/>
|
||||
</div>
|
||||
<Row align="middle" style={{ marginTop: 12, gap: 12 }} wrap={false}>
|
||||
{generating && (
|
||||
<div style={{ flex: 1, minWidth: 200 }}>
|
||||
<Progress
|
||||
percent={suiteTotal ? Math.round((doneCount / suiteTotal) * 100) : 0}
|
||||
size={['100%', 10]}
|
||||
status="active"
|
||||
format={() => `${doneCount}/${suiteTotal}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Select
|
||||
style={{ width: 260 }}
|
||||
popupMatchSelectWidth={false}
|
||||
value={model}
|
||||
onChange={setModel}
|
||||
disabled={generating}
|
||||
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
|
||||
optionRender={(option) => (
|
||||
<div>
|
||||
<div>{option.label}</div>
|
||||
<div style={{ fontSize: 11, color: '#999' }}>
|
||||
{(option as { data?: { desc?: string } }).data?.desc}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={selectedKeys.size === 0 || generating || totalPlanned === 0}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
|
||||
</Button>
|
||||
</Row>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── 生成结果 ── */}
|
||||
<Card
|
||||
size="small"
|
||||
title="生成结果"
|
||||
extra={
|
||||
suite && ['done', 'partial'].includes(suite.status) && (
|
||||
<Button size="small" icon={<DownloadOutlined />} loading={exportingZip} onClick={handleExportZip}>
|
||||
导出 ZIP
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
{!suite ? (
|
||||
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Space wrap size={8}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
状态:
|
||||
</Text>
|
||||
<Tag
|
||||
color={
|
||||
suite.status === 'done'
|
||||
? 'green'
|
||||
: suite.status === 'failed'
|
||||
? 'red'
|
||||
: 'processing'
|
||||
}
|
||||
>
|
||||
{suite.status === 'running'
|
||||
? '生成中'
|
||||
: suite.status === 'done'
|
||||
? '完成'
|
||||
: suite.status === 'partial'
|
||||
? '部分失败'
|
||||
: suite.status === 'pending'
|
||||
? '排队中'
|
||||
: '失败'}
|
||||
</Tag>
|
||||
<Tag>{suite.ratio} 图片</Tag>
|
||||
<Tag>{suite.lang}文案</Tag>
|
||||
<Tag>风格「{STYLE_SET_OPTIONS.find((s) => s.value === suite.style_set)?.label ?? '自定义'}」</Tag>
|
||||
{suite.total != null && <Tag>共 {suite.total} 张</Tag>}
|
||||
</Space>
|
||||
</div>
|
||||
<Image.PreviewGroup>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10 }}>
|
||||
{suite.images
|
||||
.filter((img) => img.status === 'ok' || img.status === 'failed')
|
||||
.map((img) => (
|
||||
<div
|
||||
key={img.type_id + img.name}
|
||||
style={{ width: 112, position: 'relative' }}
|
||||
title={img.error || img.name}
|
||||
>
|
||||
{img.status === 'ok' ? (
|
||||
<Image
|
||||
src={img.url}
|
||||
width={100}
|
||||
height={133}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 100,
|
||||
height: 133,
|
||||
borderRadius: 6,
|
||||
background: '#f5f5f5',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: '#ff4d4f',
|
||||
}}
|
||||
>
|
||||
✗
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: img.status === 'ok' ? 'rgba(0,0,0,0.45)' : '#ff4d4f',
|
||||
marginTop: 2,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={img.error || img.name}
|
||||
>
|
||||
{img.status === 'failed' && img.error ? img.error : img.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
{suite.error && <Alert type="warning" showIcon message={suite.error} style={{ marginTop: 8 }} />}
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 单张 AI 生图弹窗 */}
|
||||
<AiImageGenModal
|
||||
open={genModal !== null}
|
||||
productId={product.id}
|
||||
source={genModal}
|
||||
onClose={() => setGenModal(null)}
|
||||
onGenerated={() => onRefreshAssets()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -82,3 +82,78 @@ export function validateLogisticsLevel(sellingPriceRub: number, level: Logistics
|
||||
if (sellingPriceRub >= 135 && sellingPriceRub <= 140) return '汇率波动,建议避开 135~140 ₽ 区间';
|
||||
return '';
|
||||
}
|
||||
|
||||
// ── 以下为 v2.1 试算页从 v1 web/js/app.js:901-991 移植的校验与规则文案 ──────────
|
||||
// 注意:这三个函数的销售价单位是 CNY(与 v1 一致),不要与上面的 validateLogisticsLevel(₽)混用。
|
||||
|
||||
/** 尺寸硬校验:不合规返回错误文案,合规返回 ''(结果卡应显示 -- 且不落计价结果) */
|
||||
export function validateDimensions(
|
||||
weightG: number,
|
||||
dims: { l: number; w: number; h: number },
|
||||
level: LogisticsLevel,
|
||||
): string {
|
||||
const sides = [dims.l, dims.w, dims.h].sort((a, b) => b - a);
|
||||
const longestSide = sides[0];
|
||||
const sumOfSides = sides.reduce((s, v) => s + v, 0);
|
||||
if (level === 'low') {
|
||||
if (weightG <= 500) {
|
||||
if (sumOfSides > 90 || longestSide > 60) {
|
||||
return (
|
||||
`低等级物流重量≤500g时,要求三边之和≤90厘米且最长边≤60厘米。当前商品三边之和为` +
|
||||
`${sumOfSides.toFixed(2)}厘米,最长边为${longestSide.toFixed(2)}厘米,不符合要求。`
|
||||
);
|
||||
}
|
||||
} else if (sumOfSides > 150) {
|
||||
return `低等级物流重量>500g时,要求三边之和≤150厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
|
||||
} else if (longestSide > 60) {
|
||||
return `低等级物流重量>500g时,要求最长边≤60厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
|
||||
}
|
||||
} else {
|
||||
if (weightG <= 2000) {
|
||||
if (sumOfSides > 150) {
|
||||
return `高等级物流重量≤2000g时,要求三边之和≤150厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
|
||||
}
|
||||
if (longestSide > 60) {
|
||||
return `高等级物流重量≤2000g时,要求最长边≤60厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
|
||||
}
|
||||
} else if (sumOfSides > 250) {
|
||||
return `高等级物流重量>2000g时,要求三边之和≤250厘米。当前商品三边之和为${sumOfSides.toFixed(2)}厘米,不符合要求。`;
|
||||
} else if (longestSide > 150) {
|
||||
return `高等级物流重量>2000g时,要求最长边≤150厘米。当前商品最长边为${longestSide.toFixed(2)}厘米,不符合要求。`;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 物流等级建议(销售价单位 CNY) */
|
||||
export function validateLogisticsLevelCny(sellingPriceCny: number, level: LogisticsLevel): string {
|
||||
if (sellingPriceCny > 140 && level === 'low') {
|
||||
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,超过140元,建议选择高等级物流以提供更好的服务体验。`;
|
||||
}
|
||||
if (sellingPriceCny < 135 && level !== 'low') {
|
||||
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,低于135元,建议选择低等级物流以降低成本。`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 价格区间提示(销售价单位 CNY) */
|
||||
export function validatePriceRange(sellingPriceCny: number): string {
|
||||
if (sellingPriceCny >= 135 && sellingPriceCny <= 140) {
|
||||
return `当前销售价格为${sellingPriceCny.toFixed(2)}元,处于135-140元的区间。由于汇率波动,建议尽量避免此价格区间。`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 物流费规则文案(结果卡展示,含贴单费) */
|
||||
export function logisticsFeeRule(weightG: number, level: LogisticsLevel, tdPrice: number): string {
|
||||
let rule = '';
|
||||
if (level === 'low') {
|
||||
rule = weightG <= 500 ? 'low:3.12 + 0.026×重量' : 'low:23.92 + 0.01768×重量';
|
||||
} else if (level === 'high2') {
|
||||
rule = weightG <= 5000 ? '高2:22.88 + 0.026×重量' : '高2:64.48 + 0.024×重量';
|
||||
} else {
|
||||
rule = weightG <= 2000 ? '普通:16.64 + 0.026×重量' : '普通:37.44 + 0.01768×重量';
|
||||
}
|
||||
if (tdPrice > 0) rule += ' + 通递价';
|
||||
return rule;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import AiImagePage from '@/pages/ai-image';
|
||||
import CollectionPage from '@/pages/collection/CollectionPage';
|
||||
import ProductEditPage from '@/pages/product/ProductEditPage';
|
||||
import ShopsPage from '@/pages/shops/ShopsPage';
|
||||
import TrialPage from '@/pages/trial/TrialPage';
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -13,6 +14,7 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <Navigate to="/collection" replace /> },
|
||||
{ path: 'collection', element: <CollectionPage /> },
|
||||
{ path: 'product/:id', element: <ProductEditPage /> },
|
||||
{ path: 'trial/:id', element: <TrialPage /> },
|
||||
{ path: 'shops', element: <ShopsPage /> },
|
||||
{ path: 'ai-image', element: <AiImagePage /> },
|
||||
],
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* 套图生成 / 单张 AI 生图 / 图片导出 服务层。
|
||||
* 契约对齐 docs/v2.1/api.md(Phase A 前端先行;带 ★ 的接口待 Phase B 服务端实现)。
|
||||
* 常量(类型/风格/模型)与 image-suite-studio 的 src/api/client.ts 保持一致。
|
||||
*/
|
||||
import { api } from './api';
|
||||
import apiClient from './api';
|
||||
|
||||
/** 套图类型白名单(与服务端 prompts/common.py 对齐) */
|
||||
export const SUITE_TYPE_OPTIONS = [
|
||||
{ value: 'white_bg', label: '白底主图' },
|
||||
{ value: 'key_features', label: '核心卖点图' },
|
||||
{ value: 'selling_pt', label: '卖点图' },
|
||||
{ value: 'material', label: '材质图' },
|
||||
{ value: 'lifestyle', label: '场景展示图' },
|
||||
{ value: 'multi_scene', label: '多场景拼图' },
|
||||
{ value: 'ecommerce_detail', label: '电商详情图' },
|
||||
{ value: 'size_chart', label: '尺寸标注图' },
|
||||
{ value: 'sku_collection', label: 'SKU合集图' },
|
||||
{ value: 'custom', label: '创意图' },
|
||||
] as const;
|
||||
|
||||
/** 出图方案项:一类图 × 数量,可绑定 SKU 规格 */
|
||||
export interface PlanItem {
|
||||
kind: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
prompt_hint: string;
|
||||
count: number;
|
||||
variant_name?: string | null;
|
||||
}
|
||||
|
||||
/** 默认方案:7 种基础类型各 1 张(AI 规划前) */
|
||||
export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map((t) => ({
|
||||
kind: t.value,
|
||||
title: t.label,
|
||||
detail: '',
|
||||
prompt_hint: '',
|
||||
count: 1,
|
||||
variant_name: null,
|
||||
}));
|
||||
|
||||
/** 视觉风格(默认提示词可在页面上改写,随生成请求覆盖后端模板) */
|
||||
export const STYLE_SET_OPTIONS = [
|
||||
{ value: 1, label: '北欧极简', prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净' },
|
||||
{ value: 2, label: '清新明亮', prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净' },
|
||||
{ value: 3, label: '高级感深色', prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级' },
|
||||
{ value: 4, label: '暖调生活', prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强' },
|
||||
{ value: 5, label: '纯净棚拍', prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出' },
|
||||
] as const;
|
||||
|
||||
/** 试算页固定目标平台 Ozon:俄文文案 · 3:4 图片 */
|
||||
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
|
||||
ozon: { lang: '俄文', ratio: '3:4', label: 'Ozon' },
|
||||
};
|
||||
|
||||
/** 生图模型(服务端按模型名路由 provider,同 image-suite-studio) */
|
||||
export const IMAGE_MODEL_OPTIONS = [
|
||||
{ value: 'qwen-image-3.0-pro', label: 'qwen-image-3.0-pro', desc: '同步生成,响应快、图文理解强,适合快速批量出图' },
|
||||
{ value: 'wan2.7-image-pro', label: 'wan2.7-image-pro', desc: '异步精修,质感与细节更强,适合高质量电商大片' },
|
||||
{ value: 'wan2.6-image', label: 'wan2.6-image', desc: '通义 2.6 图生图,支持参考图与多图融合,速度更快、稳定性好' },
|
||||
{ value: 'wan2.6-t2i', label: 'wan2.6-t2i', desc: '通义 2.6 纯文生图,不使用参考图(商品外观靠文案描述),速度最快' },
|
||||
{ value: 'gpt-image-2', label: 'gpt-image-2', desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟' },
|
||||
{ value: 'gpt-image-2-vip', label: 'gpt-image-2-vip', desc: 'GPT 官逆低价通道,构图与文字渲染强、成本更低,适合大批量出图' },
|
||||
{ value: 'nano-banana', label: 'nano-banana', desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然' },
|
||||
{ value: 'nano-banana-2', label: 'nano-banana-2', desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选' },
|
||||
{ value: 'nano-banana-2-lite', label: 'nano-banana-2-lite', desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错' },
|
||||
{ value: 'nano-banana-pro', label: 'nano-banana-pro', desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片' },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_IMAGE_MODEL = 'gpt-image-2-vip';
|
||||
|
||||
export interface WatermarkPayload {
|
||||
enabled: boolean;
|
||||
type: 'image' | 'text';
|
||||
text: string;
|
||||
opacity: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_WATERMARK: WatermarkPayload = {
|
||||
enabled: false,
|
||||
type: 'text',
|
||||
text: 'xiongmaoyx',
|
||||
opacity: 30,
|
||||
};
|
||||
|
||||
/** 传给规划/生成的文本素材(kind 与采集 ScanResult.texts 一致) */
|
||||
export interface SuiteTextPayload {
|
||||
kind: string;
|
||||
content: string;
|
||||
pairs?: Array<{ key: string; value: string }> | null;
|
||||
}
|
||||
|
||||
export interface SuitePlanPayload {
|
||||
product_id?: string;
|
||||
texts: SuiteTextPayload[];
|
||||
sku_variants: string[];
|
||||
image_stats: Record<string, number>;
|
||||
platform: string;
|
||||
requirements?: string | null;
|
||||
}
|
||||
|
||||
export interface SuiteGeneratePayload {
|
||||
product_id?: string;
|
||||
texts: SuiteTextPayload[];
|
||||
images: Array<{ url: string; group_key: string; variant_name?: string | null }>;
|
||||
style_set: number;
|
||||
style_prompt?: string | null;
|
||||
requirements?: string | null;
|
||||
plan: PlanItem[];
|
||||
platform: string;
|
||||
model?: string | null;
|
||||
watermark?: WatermarkPayload;
|
||||
}
|
||||
|
||||
export interface SuiteImageInfo {
|
||||
type_id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
status: string;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface SuiteInfo {
|
||||
id: string;
|
||||
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
|
||||
style_set: number;
|
||||
platform: string;
|
||||
lang: string;
|
||||
ratio: string;
|
||||
provider?: string;
|
||||
total?: number;
|
||||
images: SuiteImageInfo[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** ★ AI 智能规划出图方案 */
|
||||
export function planSuite(payload: SuitePlanPayload) {
|
||||
return api.post<{ summary: string; items: PlanItem[] }>('/suite/plan', payload);
|
||||
}
|
||||
|
||||
/** ★ 提交一键生成任务 */
|
||||
export function generateSuite(payload: SuiteGeneratePayload) {
|
||||
return api.post<{ suite_id: string }>('/suite/generate', payload);
|
||||
}
|
||||
|
||||
/** ★ 查询套图任务状态(轮询用) */
|
||||
export function getSuite(suiteId: string) {
|
||||
return api.get<SuiteInfo>(`/suites/${suiteId}`);
|
||||
}
|
||||
|
||||
/** ★ 下载生成结果 ZIP(blob) */
|
||||
export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
|
||||
const res = await apiClient.get(`/suites/${suiteId}/zip`, { responseType: 'blob' });
|
||||
return res.data as Blob;
|
||||
}
|
||||
|
||||
export interface ImageEditSinglePayload {
|
||||
product_id?: string;
|
||||
image_url: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
append?: boolean;
|
||||
}
|
||||
|
||||
/** ★ 单张 AI 生图(采集图/生成图上的「AI生图」入口) */
|
||||
export function imageEditSingle(payload: ImageEditSinglePayload) {
|
||||
return api.post<{ url: string; asset_id: string | null }>('/suite/image-edit', payload);
|
||||
}
|
||||
|
||||
export interface ExportImagesPayload {
|
||||
title: string;
|
||||
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
|
||||
}
|
||||
|
||||
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
|
||||
export async function exportImages(payload: ExportImagesPayload): Promise<Blob> {
|
||||
const res = await apiClient.post('/export/images', payload, { responseType: 'blob' });
|
||||
return res.data as Blob;
|
||||
}
|
||||
|
||||
/** 上传补充参考图到商品素材(已有接口:POST /api/materials/bytes) */
|
||||
export async function uploadProductAsset(
|
||||
productId: string,
|
||||
file: File,
|
||||
groupKey = 'upload',
|
||||
): Promise<{ asset_id: string; status: string }> {
|
||||
const form = new FormData();
|
||||
form.append('product_id', productId);
|
||||
form.append('group_key', groupKey);
|
||||
form.append('type', 'img');
|
||||
form.append('file', file);
|
||||
const res = await apiClient.post('/materials/bytes', form);
|
||||
return res.data as { asset_id: string; status: string };
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* 文件/CSV 工具:v1 web/js/app.js 的 CSV 导出(BOM + RFC 转义)与文件名清理移植。
|
||||
*/
|
||||
|
||||
/** 文件名清理:去掉路径分隔符与常见非法字符 */
|
||||
export function cleanFilename(name: string): string {
|
||||
return (name || '').replace(/[\\/:*?"<>|\r\n]+/g, '_').trim();
|
||||
}
|
||||
|
||||
/** 二进制下载(前端环境没有 chrome.downloads,用 <a download>) */
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
|
||||
/** CSV 单元格转义:含逗号/引号/换行时加引号并双写引号 */
|
||||
function escapeCell(v: string | number | null | undefined): string {
|
||||
const s = v == null ? '' : String(v);
|
||||
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 组装 CSV 文本(UTF-8 BOM,保证 Excel 中文不乱码) */
|
||||
export function toCsv(rows: Array<Array<string | number | null | undefined>>): string {
|
||||
return '\uFEFF' + rows.map((row) => row.map(escapeCell).join(',')).join('\r\n');
|
||||
}
|
||||
|
||||
/** CSV 文本下载 */
|
||||
export function downloadCsv(rows: Array<Array<string | number | null | undefined>>, filename: string): void {
|
||||
downloadBlob(new Blob([toCsv(rows)], { type: 'text/csv;charset=utf-8' }), cleanFilename(filename) || 'export.csv');
|
||||
}
|
||||
|
||||
/** 复制文本到剪贴板(失败时降级选中文案由调用方提示) */
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
ta.remove();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/requireauth.tsx","./src/config/env.ts","./src/layouts/mainlayout.tsx","./src/layouts/sidebarmenu.tsx","./src/layouts/menuconfig.tsx","./src/pages/ai-image/aiimagepage.tsx","./src/pages/ai-image/index.ts","./src/pages/ai-image/components/annotationcanvas.tsx","./src/pages/ai-image/components/elementpropspanel.tsx","./src/pages/ai-image/components/imageeditmodal.tsx","./src/pages/ai-image/components/watermarkcanvas.tsx","./src/pages/collection/collectionpage.tsx","./src/pages/login/loginpage.tsx","./src/pages/product/attributepanel.tsx","./src/pages/product/copypanel.tsx","./src/pages/product/fieldlabel.tsx","./src/pages/product/imagepanel.tsx","./src/pages/product/maininfopanel.tsx","./src/pages/product/priceinfopanel.tsx","./src/pages/product/productattributespanel.tsx","./src/pages/product/producteditpage.tsx","./src/pages/product/publishpanel.tsx","./src/pages/shops/shopspage.tsx","./src/pages/trial/aiimagegenmodal.tsx","./src/pages/trial/trialexportpanel.tsx","./src/pages/trial/trialinfopanel.tsx","./src/pages/trial/trialpage.tsx","./src/pages/trial/trialpricingpanel.tsx","./src/pages/trial/trialsuitepanel.tsx","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.ts","./src/services/auth.ts","./src/services/category.ts","./src/services/fx.ts","./src/services/image.ts","./src/services/product.ts","./src/services/publish.ts","./src/services/shop.ts","./src/services/suite.ts","./src/types/annotation.ts","./src/types/image.ts","./src/utils/annotation.ts","./src/utils/file.ts","./src/utils/image.ts","./src/utils/watermark.ts"],"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user