feat: 试用过程中的细节优化

This commit is contained in:
2026-09-13 22:19:16 +08:00
parent dbb2bb80bd
commit 897e5585c0
40 changed files with 1156 additions and 257 deletions
+17 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd';
import { calculatePricing, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
import { calculatePricing, legacyLineMultiplierToDiscountRate, LogisticsLevel, validateLogisticsLevel } from '@/pricing/pricing';
import { getFxRate } from '@/services/fx';
import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
@@ -33,16 +33,18 @@ function dimsCm(product: ProductDetail): { l: number; w: number; h: number } {
}
/**
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线价倍数)。
* 售价信息:售价/划线价(CNY)+ 定价参数(进货价/净利率/物流等级/汇率/划线折扣率)。
* 重量、尺寸直接读「主要信息」的包装字段,不重复填写。
* 任一定价参数变化即重算并回填售价(= 销售价)与划线价(= 销售价 × (1 + 倍数%))。
* 任一定价参数变化即重算并回填售价与划线价(= 销售价 ÷ (1 - 划线折扣率))。
*/
export default function PriceInfoPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low');
const [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [discountRate, setDiscountRate] = useState(
pricing.lineDiscountRate ?? legacyLineMultiplierToDiscountRate(pricing.lineMultiplier ?? 100),
);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
@@ -67,7 +69,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
const tdPrice = pricing.tdPrice ?? 3;
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) =>
const compute = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; discountRate: number; fxRate: number }> = {}) =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
@@ -75,17 +77,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
lineDiscountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库:定价参数 + 售价/划线价一并保存 */
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; multiplier: number; fxRate: number }> = {}) => {
const recalc = (patch: Partial<{ purchasePrice: number; profitRate: number; level: LogisticsLevel; discountRate: number; fxRate: number }> = {}) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate,
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
discountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
};
const r = compute(patch);
@@ -98,7 +100,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product),
dims: dimsCm(product),
tdPrice,
lineMultiplier: p.multiplier,
lineDiscountRate: p.discountRate,
fxRate: p.fxRate,
logisticsFee: r.logisticsFee,
fullCommission: r.fullCommission,
@@ -189,15 +191,16 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
/>
</Col>
<Col span={5}>
<FieldLabel>划线价倍数 %</FieldLabel>
<FieldLabel>划线折扣率 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
value={multiplier}
max={99.99}
precision={2}
value={discountRate}
onChange={(v) => {
setMultiplier(v ?? 0);
recalc({ multiplier: v ?? 0 });
setDiscountRate(v ?? 0);
recalc({ discountRate: v ?? 0 });
}}
/>
</Col>
+139 -46
View File
@@ -1,5 +1,5 @@
import { Fragment, useEffect, useRef, useState } from 'react';
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Select, Space, Typography } from 'antd';
import { Alert, Button, Checkbox, Image, Input, InputNumber, message, Radio, Select, Space, Typography } from 'antd';
import { CloseOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
@@ -11,6 +11,8 @@ interface Props {
productId: string;
/** 待生成的源图(采集图或生成图) */
source: { url: string; name: string; afterAssetId?: string } | null;
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
referenceOptions: Array<{ url: string; name: string }>;
onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void;
@@ -24,15 +26,17 @@ interface Props {
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。
*/
export default function AiImageGenModal({ open, productId, source, onClose, onGenerated, onSubmitted, watermark }: Props) {
export default function AiImageGenModal({ open, productId, source, referenceOptions, onClose, onGenerated, onSubmitted, watermark }: Props) {
const [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
const [generateSizeChart, setGenerateSizeChart] = useState(false);
const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState('');
const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState('');
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set());
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
@@ -50,14 +54,16 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
useEffect(() => {
if (open && !activeTaskRef.current) {
setPrompt('');
setMode('edit');
setDimensions({ unit: 'mm' });
setGenerateSizeChart(false);
setResultUrl('');
setGenerating(false);
setTaskId('');
setTaskError('');
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set());
}
}, [open]);
}, [open, source?.url]);
useEffect(() => () => stopPolling(), []);
@@ -130,10 +136,18 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
const onGenerate = async () => {
if (!source) return;
const hasCompleteDimensions = [dimensions.length, dimensions.width, dimensions.height]
.every((value) => typeof value === 'number' && value > 0);
if (generateSizeChart && !hasCompleteDimensions) {
message.warning('生成尺寸标注图前,请完整填写商品长、宽、高');
if (selectedReferenceUrls.size === 0) {
message.warning('请至少选择一张参考底图');
return;
}
if (mode === 'outpaint' && selectedReferenceUrls.size !== 1) {
message.warning('仅扩图只能选择一张参考底图');
return;
}
const hasAnyDimensions = [dimensions.length, dimensions.width, dimensions.height]
.some((value) => typeof value === 'number' && value > 0);
if (generateSizeChart && !hasAnyDimensions) {
message.warning('生成尺寸标注图前,请至少填写一项商品尺寸');
return;
}
setGenerating(true);
@@ -142,14 +156,15 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
try {
const r = await imageEditSingle({
product_id: productId,
image_url: source.url,
image_urls: Array.from(selectedReferenceUrls),
prompt: prompt.trim(),
mode,
model,
append: true,
after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined,
generate_size_chart: generateSizeChart,
dimensions: hasCompleteDimensions ? dimensions : null,
dimensions: hasAnyDimensions ? dimensions : null,
});
setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示');
@@ -181,6 +196,36 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
}
};
const referenceImages = Array.from(
new Map(
[...referenceOptions, ...(source ? [{ url: source.url, name: source.name }] : [])]
.filter((image) => image.url)
.map((image) => [image.url, image]),
).values(),
);
const toggleReference = (url: string) => {
if (generating) return;
setSelectedReferenceUrls((previous) => {
if (mode === 'outpaint') return new Set([url]);
const next = new Set(previous);
if (next.has(url)) next.delete(url); else next.add(url);
return next;
});
};
const changeMode = (nextMode: 'edit' | 'outpaint') => {
setMode(nextMode);
if (nextMode === 'outpaint') {
setGenerateSizeChart(false);
setModel('gpt-image-2@pcore');
setSelectedReferenceUrls((previous) => {
const first = previous.values().next().value as string | undefined;
return new Set(first ? [first] : (source?.url ? [source.url] : []));
});
}
};
return (
<>
{open && (
@@ -209,52 +254,106 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
onClick={(e) => e.stopPropagation()}
>
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text>
<Radio.Group
size="small"
value={mode}
buttonStyle="solid"
disabled={generating}
onChange={(e) => changeMode(e.target.value)}
>
<Radio.Button value="edit">AI 编辑</Radio.Button>
<Radio.Button value="outpaint">仅扩图至 3:4</Radio.Button>
</Radio.Group>
</div>
<Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
</div>
<div style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 12, marginBottom: 7 }}>
<Text strong style={{ fontSize: 13 }}>参考底图{mode === 'outpaint' ? '(仅限单张)' : '(可多选)'}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>已选 {selectedReferenceUrls.size} 张</Text>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, minmax(0, 1fr))', gap: 8, maxHeight: 190, overflowY: 'auto', padding: 2 }}>
{referenceImages.map((image) => {
const selected = selectedReferenceUrls.has(image.url);
return (
<button
key={image.url}
type="button"
title={`${selected ? '取消选择' : '选择'}:${image.name}`}
onClick={() => toggleReference(image.url)}
disabled={generating}
style={{ position: 'relative', padding: 2, border: `2px solid ${selected ? '#1677ff' : '#e5e7eb'}`, borderRadius: 7, background: selected ? '#e6f4ff' : '#fff', cursor: generating ? 'not-allowed' : 'pointer', aspectRatio: '1 / 1', overflow: 'hidden' }}
>
<img src={image.url} alt={image.name} style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block', borderRadius: 4 }} />
{selected && <span style={{ position: 'absolute', top: 3, right: 3, width: 18, height: 18, borderRadius: '50%', background: '#1677ff', color: '#fff', fontSize: 12, lineHeight: '18px' }}>✓</span>}
</button>
);
})}
</div>
</div>
<div style={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}>
<Image
src={source?.url}
alt="原图"
alt="当前图片"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}>
<InputNumber
min={0}
precision={2}
controls={false}
style={{ width: '100%' }}
value={dimensions[key]}
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
placeholder={['长', '宽', '高'][index]}
{mode === 'edit' && <div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: generateSizeChart ? 12 : 0 }}>
<Checkbox
checked={generateSizeChart}
onChange={(e) => setGenerateSizeChart(e.target.checked)}
disabled={generating}
>
生成尺寸标注图(未知方向留空)
</Checkbox>
</div>}
{mode === 'edit' && generateSizeChart && (
<div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
{(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}>
<InputNumber
min={0}
precision={2}
controls={false}
style={{ width: '100%' }}
value={dimensions[key]}
onChange={(value) => setDimensions((prev) => ({ ...prev, [key]: typeof value === 'number' ? value : undefined }))}
placeholder={['长', '宽', '高'][index]}
disabled={generating}
/>
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
</Fragment>
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
disabled={generating}
/>
{index < 2 && <Text type="secondary" style={{ fontSize: 16 }}>×</Text>}
</Fragment>
))}
<Select
style={{ width: 76, flexShrink: 0 }}
value={dimensions.unit ?? 'mm'}
onChange={(unit: 'mm' | 'cm') => setDimensions((prev) => ({ ...prev, unit }))}
options={[{ value: 'mm', label: 'mm' }, { value: 'cm', label: 'cm' }]}
disabled={generating}
/>
</div>
</div>
</div>
)}
<Input.TextArea
rows={5}
rows={4}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'}
placeholder={mode === 'outpaint'
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
disabled={generating}
/>
{mode === 'outpaint' && (
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginTop: 6 }}>
系统会生成 3:4 画布,只允许 AI 生成新增边缘;完成后会重新覆盖原图区域,确保原图不被改动。
</Text>
)}
<div style={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select
@@ -263,7 +362,9 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
value={model}
onChange={setModel}
disabled={generating}
options={IMAGE_MODEL_OPTIONS.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
options={IMAGE_MODEL_OPTIONS
.filter((m) => mode !== 'outpaint' || m.value === 'gpt-image-2@pcore')
.map((m) => ({ value: m.value, label: m.label, desc: m.desc }))}
optionRender={(option) => (
<div>
<div>{option.label}</div>
@@ -274,14 +375,6 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
)}
/>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'stretch', gap: 4, flexShrink: 0 }}>
<Checkbox
checked={generateSizeChart}
onChange={(e) => setGenerateSizeChart(e.target.checked)}
disabled={generating}
style={{ whiteSpace: 'nowrap' }}
>
生成尺寸标注图
</Checkbox>
<Button type="primary" loading={generating} onClick={onGenerate}>
提交任务
</Button>
+2 -2
View File
@@ -29,7 +29,7 @@ interface RegisterRecord {
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
sellingPriceRub: string;
sellingPriceRubReserved: string; // 划线价 ₽
lineMultiplier: number; // 划线价倍数 %(v1 discountReserve 位)
lineDiscountRate: number; // 划线折扣率 %
exchangeRate: string;
logisticsFee: string;
receivedPrice: string;
@@ -95,7 +95,7 @@ function toRecord(product: ProductDetail): RegisterRecord | null {
sellingPriceReserved: f(pr.linePriceCny),
sellingPriceRub: f(pr.sellingPriceRub),
sellingPriceRubReserved: f(pr.linePriceRub),
lineMultiplier: pr.lineMultiplier ?? 100,
lineDiscountRate: pr.lineDiscountRate ?? 50,
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
logisticsFee: f(pr.logisticsFee),
receivedPrice: f(pr.receivedPrice),
+56 -41
View File
@@ -7,6 +7,7 @@ import { apiErrorMessage } from '@/services/api';
import { copyText } from '@/utils/file';
import {
calculatePricing,
legacyLineMultiplierToDiscountRate,
logisticsFeeRule,
validateDimensions,
validateLogisticsLevelCny,
@@ -30,7 +31,7 @@ interface Props {
type PricingPayload = Record<string, any>;
type Dims = { l: number | null; w: number | null; h: number | null };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number; weightG: number; dims: Dims }>;
type PricingState = Partial<{ purchasePrice: number | null; profitRate: number; tdPrice: number; level: LogisticsLevel; discountRate: number; fxRate: number; weightG: number | null; dims: Dims }>;
function dimsCm(product: ProductDetail): Dims {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
@@ -60,7 +61,7 @@ function SalePriceGrid({
cnyLine,
rubSelling,
rubLine,
multiplier,
discountRate,
fxRate,
cnyGap,
rubGap,
@@ -70,7 +71,7 @@ function SalePriceGrid({
cnyLine: string;
rubSelling: string;
rubLine: string;
multiplier: number;
discountRate: number;
fxRate: number;
cnyGap: number | null;
rubGap: number | null;
@@ -88,7 +89,7 @@ function SalePriceGrid({
lineHeight: '30px',
};
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' };
const multiple = (1 + multiplier / 100).toFixed(2);
const discountText = `${discountRate.toFixed(2).replace(/\.00$/, '')}%`;
const copyCnySelling = async () => {
if (!cnySellingNumber) return;
@@ -133,7 +134,7 @@ function SalePriceGrid({
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
<div style={noteStyle}>
销售价 × {multiple}
划线折扣 {discountText}
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
</div>
</div>
@@ -141,7 +142,7 @@ function SalePriceGrid({
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
<div style={noteStyle}>
销售价 × {multiple}
划线折扣 {discountText}
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
</div>
</div>
@@ -153,18 +154,20 @@ function SalePriceGrid({
/**
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
* 划线价为「倍数」口径:划线价 = 销售价 × (1 + 倍数%),0-500% 线性可算(v2.1 调整)。
* 划线价按折扣率计算:划线价 = 销售价 ÷ (1 - 划线折扣率)。
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
* 尺寸不合规时不落计价结果。
*/
export default function TrialPricingPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [purchasePrice, setPurchasePrice] = useState<number | null>(pricing.purchasePrice ?? null);
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 [multiplier, setMultiplier] = useState(pricing.lineMultiplier ?? 100);
const [discountRate, setDiscountRate] = useState(
pricing.lineDiscountRate ?? legacyLineMultiplierToDiscountRate(pricing.lineMultiplier ?? 100),
);
const [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
const [fxSource, setFxSource] = useState('');
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
@@ -206,39 +209,43 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const compute = (patch: PricingState = {}): PricingResult =>
calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice,
purchasePrice: patch.purchasePrice ?? purchasePrice ?? 0,
profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level,
weightG: wG(patch.weightG ?? weight),
dims: dimsNum(patch.dims ?? dims),
tdPrice: patch.tdPrice ?? tdPrice,
lineMultiplier: patch.multiplier ?? multiplier,
lineDiscountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
});
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice,
purchasePrice: patch.purchasePrice !== undefined ? patch.purchasePrice : purchasePrice,
profitRate: patch.profitRate ?? profitRate,
tdPrice: patch.tdPrice ?? tdPrice,
level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier,
discountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate,
weightG: wG(patch.weightG ?? weight),
dimsNow: dimsNum(patch.dims ?? dims),
weightG: patch.weightG !== undefined ? patch.weightG : weight,
dimsNow: patch.dims ?? dims,
};
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level);
const r = dimError ? null : calculatePricing({
purchasePrice: p.purchasePrice,
const complete = p.purchasePrice != null && p.weightG != null
&& [p.dimsNow.l, p.dimsNow.w, p.dimsNow.h].every((value) => value != null)
&& p.fxRate > 0;
const dimsForCalculation = dimsNum(p.dimsNow);
const dimError = complete ? validateDimensions(p.weightG!, dimsForCalculation, p.level) : '';
const r = complete && !dimError ? calculatePricing({
purchasePrice: p.purchasePrice!,
profitRate: p.profitRate,
logisticsLevel: p.level,
weightG: p.weightG,
dims: p.dimsNow,
weightG: p.weightG!,
dims: dimsForCalculation,
tdPrice: p.tdPrice,
lineMultiplier: p.multiplier,
lineDiscountRate: p.discountRate,
fxRate: p.fxRate,
});
}) : null;
onSave({
...extraSave,
pricing: {
@@ -247,7 +254,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
profitRate: p.profitRate,
tdPrice: p.tdPrice,
logisticsLevel: p.level,
lineMultiplier: p.multiplier,
lineDiscountRate: p.discountRate,
fxRate: p.fxRate,
weightG: p.weightG,
dims: p.dimsNow,
@@ -276,7 +283,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
const onWeightChange = (v: number | null) => {
setWeight(v);
recalc({ weightG: v ?? 0 }, { weight: v, weight_unit: 'g' });
recalc({ weightG: v }, { weight: v, weight_unit: 'g' });
};
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
@@ -292,8 +299,11 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const curWeightG = wG(weight);
const curDims = dimsNum(dims);
const dimError = fxRate ? validateDimensions(curWeightG, curDims, level) : '';
const preview = fxRate && !dimError ? compute() : null;
const hasCompleteInputs = purchasePrice != null && weight != null
&& [dims.l, dims.w, dims.h].every((value) => value != null)
&& fxRate > 0;
const dimError = hasCompleteInputs ? validateDimensions(curWeightG, curDims, level) : '';
const preview = hasCompleteInputs && !dimError ? compute() : null;
const levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
@@ -310,6 +320,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={weight}
onChange={(v) => onWeightChange(v)}
/>
@@ -319,6 +330,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={dims.l}
onChange={(v) => onDimChange('l', v)}
/>
@@ -328,6 +340,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={dims.w}
onChange={(v) => onDimChange('w', v)}
/>
@@ -337,6 +350,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={dims.h}
onChange={(v) => onDimChange('h', v)}
/>
@@ -344,15 +358,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
</Row>
<Row gutter={16} style={fieldRowStyle}>
<Col span={12}>
<FieldLabel>进货价 ¥</FieldLabel>
<FieldLabel>净货价 ¥</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
placeholder="请填写"
value={purchasePrice}
onChange={(v) => {
const n = v ?? 0;
setPurchasePrice(n);
recalc({ purchasePrice: n });
setPurchasePrice(v);
recalc({ purchasePrice: v });
}}
/>
</Col>
@@ -385,17 +399,18 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/>
</Col>
<Col span={12}>
<FieldLabel>划线价倍数 %</FieldLabel>
<FieldLabel>划线折扣率 %</FieldLabel>
<InputNumber
style={{ width: '100%' }}
min={0}
max={500}
step={5}
value={multiplier}
max={99.99}
precision={2}
step={1}
value={discountRate}
onChange={(v) => {
const n = v ?? 0;
setMultiplier(n);
recalc({ multiplier: n });
setDiscountRate(n);
recalc({ discountRate: n });
}}
/>
</Col>
@@ -450,7 +465,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
划线价 = 销售价 × (1 + 倍数%);参数与重量/尺寸变化自动计价并入库;尺寸不合规时不落计价结果。
划线价 = 销售价 ÷ (1 − 划线折扣率);折扣率需 ≥ 0 且 &lt; 100%。填写净货价、重量和长宽高后自动计价。
</Text>
{(levelHint || rangeHint) && (
@@ -496,15 +511,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<Metric
label="完全成本"
value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
rule="(进货价 + 物流费 + 平台总抽成)"
rule="(净货价 + 物流费 + 平台总抽成)"
color={COLOR_RED}
/>
</Col>
<Col span={8}>
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" />
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(净货价 × 净利率)" />
</Col>
<Col span={8}>
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" />
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(净货价 × (1 + 净利率))" />
</Col>
</Row>
@@ -515,7 +530,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'}
multiplier={multiplier}
discountRate={discountRate}
fxRate={fxRate}
cnyGap={preview ? discountGap : null}
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : null}
+130 -49
View File
@@ -28,8 +28,8 @@ const { Text } = Typography;
const WATERMARK_STORAGE_KEY = 'trialWatermark';
const hasCompleteDimensions = (dimensions: ProductDimensions) =>
[dimensions.length, dimensions.width, dimensions.height].every((value) => typeof value === 'number' && value > 0);
const hasAnyDimensions = (dimensions: ProductDimensions) =>
[dimensions.length, dimensions.width, dimensions.height].some((value) => typeof value === 'number' && value > 0);
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
@@ -168,6 +168,8 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
/** 同一商品可并行生成多套图:这里保留所有仍在运行的任务,避免新任务挤掉旧任务。 */
const [activeSuiteTasks, setActiveSuiteTasks] = useState<SuiteInfo[]>([]);
const [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = useState(false);
/** 生图结果选择:套图当次结果与已落库的单张/套图历史共用一套选择状态。 */
@@ -177,9 +179,10 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
const [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
const [applyingWatermark, setApplyingWatermark] = useState(false);
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0);
const suitePollersRef = useRef(new Map<string, { timer: ReturnType<typeof setTimeout> | null; controller: AbortController }>());
const activeSuiteIdsRef = useRef(new Set<string>());
/** 当前展示的任务;其他任务继续轮询,但不能把当前结果面板覆盖掉。 */
const focusedSuiteIdRef = useRef<string | null>(null);
const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const watermarkPollAbortRef = useRef<AbortController | null>(null);
const watermarkPollRunRef = useRef(0);
@@ -237,6 +240,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}, [generatedAssets, hiddenResultKeys, suite]);
const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
const historyResultImages = resultImages.filter((img) => img.source === 'history');
const otherActiveSuiteTasks = activeSuiteTasks.filter((task) => task.id !== suite?.id);
const selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => img.assetId!)));
@@ -263,12 +267,16 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets]);
const stopSuitePolling = (suiteId: string) => {
const poller = suitePollersRef.current.get(suiteId);
if (!poller) return;
if (poller.timer) clearTimeout(poller.timer);
poller.controller.abort();
suitePollersRef.current.delete(suiteId);
};
const stopPolling = () => {
pollRunRef.current += 1;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current?.abort();
pollAbortRef.current = null;
Array.from(suitePollersRef.current.keys()).forEach(stopSuitePolling);
};
const toggleKey = (key: string) => {
@@ -344,39 +352,59 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
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 runId = ++pollRunRef.current;
const pollSuite = (suiteId: string, focus = true) => {
// 只替换同一任务的轮询,绝不停止其他正在运行的套图任务。
stopSuitePolling(suiteId);
const controller = new AbortController();
pollAbortRef.current = controller;
const poller = { timer: null as ReturnType<typeof setTimeout> | null, controller };
suitePollersRef.current.set(suiteId, poller);
activeSuiteIdsRef.current.add(suiteId);
setGenerating(true);
if (focus) focusedSuiteIdRef.current = suiteId;
const startedAt = Date.now();
let failCount = 0;
let timeoutWarned = false;
const storageKey = `trialSuiteTasks:${product.id}`;
const legacyStorageKey = `trialSuiteTask:${product.id}`;
try {
sessionStorage.setItem(`trialSuiteTask:${product.id}`, suiteId);
const raw = sessionStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : [];
const ids = Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string') : [];
if (!ids.includes(suiteId)) ids.unshift(suiteId);
sessionStorage.setItem(storageKey, JSON.stringify(ids));
sessionStorage.removeItem(legacyStorageKey);
} catch { /* 私密模式等不可写时不影响当前轮询 */ }
const clearSavedTask = () => {
try {
sessionStorage.removeItem(`trialSuiteTask:${product.id}`);
const raw = sessionStorage.getItem(storageKey);
const saved = raw ? JSON.parse(raw) : [];
const ids = Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string' && id !== suiteId) : [];
if (ids.length) sessionStorage.setItem(storageKey, JSON.stringify(ids));
else sessionStorage.removeItem(storageKey);
sessionStorage.removeItem(legacyStorageKey);
} catch { /* 忽略 sessionStorage 不可用 */ }
};
const finish = () => {
if (runId !== pollRunRef.current) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current = null;
setGenerating(false);
if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
stopSuitePolling(suiteId);
activeSuiteIdsRef.current.delete(suiteId);
setActiveSuiteTasks((previous) => previous.filter((task) => task.id !== suiteId));
setGenerating(activeSuiteIdsRef.current.size > 0);
};
const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs);
poller.timer = setTimeout(() => void poll(), delayMs);
};
const poll = async () => {
if (runId !== pollRunRef.current || controller.signal.aborted) return;
if (suitePollersRef.current.get(suiteId)?.controller !== controller || controller.signal.aborted) return;
try {
const s = await getSuite(suiteId, { signal: controller.signal });
if (runId !== pollRunRef.current) return;
setSuite(s);
if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
setActiveSuiteTasks((previous) => {
const next = previous.filter((task) => task.id !== suiteId);
return ['done', 'partial', 'failed', 'cancelled'].includes(s.status) ? next : [...next, s];
});
if (focusedSuiteIdRef.current === suiteId) setSuite(s);
failCount = 0;
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
@@ -397,7 +425,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。
schedule(3000);
} catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return;
if (controller.signal.aborted || suitePollersRef.current.get(suiteId)?.controller !== controller) return;
failCount += 1;
if (failCount >= 3) {
finish();
@@ -479,25 +507,41 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
schedule(0);
};
// 刷新试算页后自动恢复当前商品仍在运行的套图任务;服务重启导致的任务丢失会按常规错误提示处理。
// 刷新试算页后恢复当前商品的所有运行任务,而不是仅恢复最新一条。
useEffect(() => {
let savedTaskId = '';
let savedTaskIds: string[] = [];
try {
savedTaskId = sessionStorage.getItem(`trialSuiteTask:${product.id}`) || '';
const raw = sessionStorage.getItem(`trialSuiteTasks:${product.id}`);
const parsed = raw ? JSON.parse(raw) : [];
savedTaskIds = Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : [];
// 兼容旧版本只保存单个任务 ID 的会话记录。
if (savedTaskIds.length === 0) {
const legacy = sessionStorage.getItem(`trialSuiteTask:${product.id}`);
if (legacy) savedTaskIds = [legacy];
}
} catch { /* sessionStorage 不可用时跳过恢复 */ }
if (savedTaskId) {
setGenerating(true);
pollSuite(savedTaskId);
if (savedTaskIds.length > 0) {
void Promise.all(savedTaskIds.map((id) => getSuite(id).catch(() => null))).then((tasks) => {
const recoverable = tasks.filter((task): task is SuiteInfo => task !== null);
if (recoverable.length === 0) return;
focusedSuiteIdRef.current = recoverable[0].id;
setSuite(recoverable[0]);
recoverable.forEach((task, index) => pollSuite(task.id, index === 0));
});
} else {
void listProductSuites(product.id, true).then((active) => {
const task = active[0];
if (!task) return;
setSuite(task);
setGenerating(true);
pollSuite(task.id);
if (active.length === 0) return;
focusedSuiteIdRef.current = active[0].id;
setSuite(active[0]);
active.forEach((task, index) => pollSuite(task.id, index === 0));
}).catch(() => { /* 网络失败时不打断试算页使用 */ });
}
return () => stopPolling();
return () => {
stopPolling();
activeSuiteIdsRef.current.clear();
setActiveSuiteTasks([]);
setGenerating(false);
};
// 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [product.id]);
@@ -535,11 +579,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
message.warning('出图方案的张数都是 0');
return;
}
if (activePlan.some((item) => item.kind === 'size_chart') && !hasCompleteDimensions(dimensions)) {
message.warning('方案包含尺寸标注图,请先在右侧完整填写商品长、宽、高及单位,或将该项数量设为 0');
if (activePlan.some((item) => item.kind === 'size_chart') && !hasAnyDimensions(dimensions)) {
message.warning('方案包含尺寸标注图,请先在右侧填写至少一项商品尺寸及单位,或将该项数量设为 0');
return;
}
setGenerating(true);
focusedSuiteIdRef.current = null;
setSuite(null);
try {
const { suite_id } = await generateSuite({
@@ -551,7 +596,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
style_set: styleSet,
style_prompt: currentStylePrompt || null,
requirements: requirements.trim() || null,
dimensions: hasCompleteDimensions(dimensions) ? dimensions : null,
dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
plan: activePlan,
platform: 'ozon',
model,
@@ -570,9 +615,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
try {
const next = await cancelSuite(suite.id);
setSuite(next);
stopPolling();
setGenerating(false);
try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* ignore */ }
stopSuitePolling(suite.id);
activeSuiteIdsRef.current.delete(suite.id);
setActiveSuiteTasks((previous) => previous.filter((task) => task.id !== suite.id));
setGenerating(activeSuiteIdsRef.current.size > 0);
try {
const raw = sessionStorage.getItem(`trialSuiteTasks:${product.id}`);
const saved = raw ? JSON.parse(raw) : [];
const ids = Array.isArray(saved) ? saved.filter((id): id is string => typeof id === 'string' && id !== suite.id) : [];
if (ids.length) sessionStorage.setItem(`trialSuiteTasks:${product.id}`, JSON.stringify(ids));
else sessionStorage.removeItem(`trialSuiteTasks:${product.id}`);
sessionStorage.removeItem(`trialSuiteTask:${product.id}`);
} catch { /* ignore */ }
message.info('已取消生成任务;已完成的图片会保留在生成记录中');
} catch (e) {
message.error(apiErrorMessage(e));
@@ -650,7 +704,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
image_stats: stats,
platform: 'ozon',
requirements: requirements.trim() || null,
dimensions: hasCompleteDimensions(dimensions) ? dimensions : null,
dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
});
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
setPlan(data.items);
@@ -696,7 +750,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
});
};
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ──
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,统一放在 ZIP 根目录) ──
const handleExportResult = async () => {
if (resultImages.length === 0) {
message.warning('暂无生图结果可导出');
@@ -715,7 +769,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
variantName: img.variantName,
key: img.key,
}));
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images });
const blob = await exportImages({ title: `${cleanFilename(productTitle) || '生图'}-生图结果`, images, flat: true });
downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) {
message.error(apiErrorMessage(e));
@@ -1144,7 +1198,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}>商品尺寸(长 × 宽 × 高)</Text>
<Text type="secondary" style={{ fontSize: 12 }}>商品尺寸(长 × 宽 × 高;未知方向可留空)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
{(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}>
@@ -1253,6 +1307,30 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" />
) : (
<>
{otherActiveSuiteTasks.length > 0 && (
<Alert
type="info"
showIcon
style={{ marginBottom: 12 }}
message={`另有 ${otherActiveSuiteTasks.length} 个任务仍在生成,结果会自动保留到下方生图记录`}
description={
<Space wrap size={6} style={{ marginTop: 6 }}>
{otherActiveSuiteTasks.map((task) => (
<Button
key={task.id}
size="small"
onClick={() => {
focusedSuiteIdRef.current = task.id;
setSuite(task);
}}
>
查看任务 · {task.completed ?? 0}/{task.total ?? 0}
</Button>
))}
</Space>
}
/>
)}
{suite && (
<>
<div style={{ marginBottom: 8 }}>
@@ -1356,11 +1434,14 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
open={genModal !== null}
productId={product.id}
source={genModal}
referenceOptions={[
...imgs.map((asset) => ({ url: assetOriginalUrl(asset), name: assetName(asset) })),
...generatedAssets.map((asset) => ({ url: asset.original_url || assetUrl(asset), name: assetName(asset) })),
]}
watermark={watermark.enabled ? watermark : undefined}
onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()}
onSubmitted={(suiteId) => {
setGenerating(true);
pollSuite(suiteId);
}}
/>
+18 -4
View File
@@ -3,7 +3,8 @@ import { CloseOutlined, DeleteOutlined, DownloadOutlined, LoadingOutlined, Reloa
import { Alert, App, Button, Card, Empty, Image, Modal, Popconfirm, Space, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product';
import { apiErrorMessage } from '@/services/api';
import { cancelVideoJob, createVideoJob, deleteVideoJob, listVideoJobs, retryVideoJob, videoDownloadUrl, type VideoJobInfo } from '@/services/suite';
import { cancelVideoJob, createVideoJob, deleteVideoJob, downloadVideo, listVideoJobs, retryVideoJob, type VideoJobInfo } from '@/services/suite';
import { downloadBlob } from '@/utils/file';
const { Text } = Typography;
const urlOf = (asset: ProductAsset) => asset.stored_url || asset.source_url;
@@ -11,12 +12,14 @@ const nameOf = (asset: ProductAsset) => decodeURIComponent((asset.source_url ||
const statusText = (status: VideoJobInfo['status']) => ({ queued: '排队中', running: '合成中', done: '已完成', partial: '部分完成', failed: '失败', cancelled: '已取消' } as const)[status];
const statusColor = (status: VideoJobInfo['status']) => status === 'done' ? 'green' : status === 'failed' ? 'red' : status === 'partial' ? 'orange' : status === 'cancelled' ? 'default' : 'processing';
function VideoCard({ job, removing, onCancel, onDelete, onRetry }: {
function VideoCard({ job, removing, downloading, onCancel, onDelete, onRetry, onDownload }: {
job: VideoJobInfo;
removing: boolean;
downloading: boolean;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onRetry: (id: string) => void;
onDownload: (id: string) => void;
}) {
const active = ['queued', 'running'].includes(job.status);
const done = job.status === 'done' && !!job.result?.url;
@@ -40,7 +43,7 @@ function VideoCard({ job, removing, onCancel, onDelete, onRetry }: {
<div style={{ display: 'flex', alignItems: 'center', gap: 7, minWidth: 0, marginTop: 8 }}>
<Tag color={statusColor(job.status)} style={{ margin: 0, fontSize: 13 }}>{statusText(job.status)}</Tag>
<Text type="secondary" style={{ minWidth: 0, fontSize: 13, whiteSpace: 'nowrap' }}>{job.source_asset_ids.length} 图 · {job.duration_seconds ? `${job.duration_seconds.toFixed(1)} 秒` : `每图 ${job.slide_seconds} 秒`}</Text>
{done && <Button type="link" size="small" icon={<DownloadOutlined />} href={videoDownloadUrl(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>下载 MP4</Button>}
{done && <Button type="link" size="small" icon={<DownloadOutlined />} loading={downloading} onClick={() => onDownload(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>下载 MP4</Button>}
{failed && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={() => onRetry(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>重试</Button>}
</div>
{job.error && <Text type="danger" ellipsis={{ tooltip: job.error }} style={{ display: 'block', fontSize: 12, marginTop: 5 }}>{job.error}</Text>}
@@ -53,6 +56,7 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
const [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false);
const [removingId, setRemovingId] = useState<string | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [jobs, setJobs] = useState<VideoJobInfo[]>([]);
const candidates = useMemo(() => assets.filter((asset) => asset.type === 'img' && asset.status === 'uploaded' && !!urlOf(asset)).sort((a, b) => Number(b.group_key === 'generated') - Number(a.group_key === 'generated') || (b.created_at || '').localeCompare(a.created_at || '')), [assets]);
@@ -75,13 +79,23 @@ export default function TrialVideoMaker({ productId, assets }: { productId: stri
try { await deleteVideoJob(id); setJobs((prev) => prev.filter((job) => job.id !== id)); } catch (error) { message.error(apiErrorMessage(error)); } finally { setRemovingId(null); }
};
const retry = async (id: string) => { try { await retryVideoJob(id); await loadJobs(); } catch (error) { message.error(apiErrorMessage(error)); } };
const download = async (id: string) => {
setDownloadingId(id);
try {
downloadBlob(await downloadVideo(id), `product-video-${id}.mp4`);
} catch (error) {
message.error(apiErrorMessage(error));
} finally {
setDownloadingId(null);
}
};
return <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<div><Text strong>视频制作</Text><Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>选择 2–5 张图片,合成 810×1080 无音轨轮播 MP4</Text></div>
<Button type="primary" size="small" icon={<VideoCameraOutlined />} disabled={candidates.length < 2} onClick={() => setOpen(true)}>合成视频</Button>
</div>
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} />)}</div>}
{jobs.filter((job) => job.status !== 'cancelled').length === 0 ? <Text type="secondary" style={{ display: 'block', marginTop: 10, fontSize: 12 }}>生成的轮播视频会保存在这里,可随时下载。</Text> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(230px, 1fr))', alignItems: 'start', gap: 10, marginTop: 12 }}>{jobs.filter((job) => job.status !== 'cancelled').slice(0, 8).map((job) => <VideoCard key={job.id} job={job} removing={removingId === job.id} downloading={downloadingId === job.id} onCancel={(id) => void cancel(id)} onDelete={(id) => void remove(id)} onRetry={(id) => void retry(id)} onDownload={(id) => void download(id)} />)}</div>}
<Modal open={open} onCancel={() => setOpen(false)} onOk={() => void submit()} okText={`合成视频(${selected.length}/5)`} okButtonProps={{ disabled: selected.length < 2, loading: submitting }} width={900} title="选择轮播图片">
<Alert type="info" showIcon message="AI 生成图排在前面;点击图片按播放顺序选择。" style={{ marginBottom: 12 }} />
{candidates.length < 2 ? <Empty description="至少需要两张已转存图片" /> : <div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10, maxHeight: 430, overflowY: 'auto' }}>{candidates.map((asset) => { const index = selected.indexOf(asset.id); return <div key={asset.id} onClick={() => toggle(asset.id)} style={{ cursor: 'pointer', border: `2px solid ${index >= 0 ? '#1677ff' : 'transparent'}`, borderRadius: 7, padding: 2, position: 'relative' }}><Image preview={false} loading="lazy" src={urlOf(asset)} width="100%" style={{ aspectRatio: '3 / 4', objectFit: 'cover', borderRadius: 5 }} />{index >= 0 && <span style={{ position: 'absolute', top: 6, right: 6, background: '#1677ff', color: '#fff', borderRadius: 12, minWidth: 22, textAlign: 'center', lineHeight: '22px' }}>{index + 1}</span>}<div style={{ fontSize: 11, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{asset.group_key === 'generated' ? 'AI 图 · ' : ''}{nameOf(asset)}</div></div>; })}</div>}
+15 -6
View File
@@ -1,7 +1,7 @@
/**
* 计价纯函数 —— 从 v1 web/js/app.js 抄录(只抄不改,见 docs/v2/migration.md §3)。
* v2.1 调整:划线价改为「倍数」口径(划线价 = 销售价 × (1 + 倍数%)),替代 v1 的 ÷(1-预留折扣)。
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/划线价倍数/汇率
* 划线价按「划线折扣率」计算:划线价 = 销售价 ÷ (1 - 划线折扣率)。
* 输入:进货价/净利率/物流等级/重量/尺寸/贴单费/划线折扣率/汇率
* 输出:物流费/佣金/完全成本/销售价(¥/₽)/划线价(¥/₽)
*/
@@ -14,7 +14,7 @@ export interface PricingInput {
weightG: number; // 重量 g
dims: { l: number; w: number; h: number }; // cm
tdPrice: number; // 贴单费用 ¥
lineMultiplier: number; // 划线价倍数 %(划线价 = 销售价 × (1 + 倍数/100))
lineDiscountRate: number; // 划线折扣率 %(划线价 = 销售价 ÷ (1 - 折扣率/100))
fxRate: number; // 汇率 CNY→RUB
}
@@ -28,7 +28,7 @@ export interface PricingResult {
commission: number; // 展示用平台佣金
totalCost: number; // 完全成本
sellingPriceRub: number; // 销售价 ₽
linePriceCny: number; // 划线价 ¥(销售价 × (1 + 倍数))
linePriceCny: number; // 划线价 ¥
linePriceRub: number; // 划线价 ₽
}
@@ -45,7 +45,10 @@ export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number
}
export function calculatePricing(input: PricingInput): PricingResult {
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, lineMultiplier, fxRate } = input;
const { purchasePrice, profitRate, logisticsLevel, weightG, tdPrice, lineDiscountRate, fxRate } = input;
if (!Number.isFinite(lineDiscountRate) || lineDiscountRate < 0 || lineDiscountRate >= 100) {
throw new RangeError('划线折扣率必须大于等于 0 且小于 100');
}
const logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
const profitDecimal = profitRate / 100;
@@ -58,7 +61,7 @@ export function calculatePricing(input: PricingInput): PricingResult {
const totalCost = purchasePrice + logisticsFee + fullCommission;
const sellingPriceRub = sellingPriceCny * fxRate;
const linePriceCny = sellingPriceCny * (1 + lineMultiplier / 100);
const linePriceCny = sellingPriceCny / (1 - lineDiscountRate / 100);
const linePriceRub = linePriceCny * fxRate;
return {
@@ -76,6 +79,12 @@ export function calculatePricing(input: PricingInput): PricingResult {
};
}
/** 将历史「划线价倍数」迁移为等价的「划线折扣率」。 */
export function legacyLineMultiplierToDiscountRate(lineMultiplier: number): number {
const multiplier = Math.max(0, Number.isFinite(lineMultiplier) ? lineMultiplier : 0);
return (multiplier / (100 + multiplier)) * 100;
}
/** 尺寸/物流等级建议(纯提示,不阻断) */
export function validateLogisticsLevel(sellingPriceRub: number, level: LogisticsLevel): string {
if (sellingPriceRub > 140 && level === 'low') return '售价较高,建议选高等级物流';
+12 -3
View File
@@ -198,8 +198,13 @@ export async function downloadSuiteZip(suiteId: string): Promise<Blob> {
export interface ImageEditSinglePayload {
product_id?: string;
image_url: string;
/** 兼容旧接口的单张底图;新界面使用 image_urls。 */
image_url?: string;
/** 选中的参考底图,可多张。 */
image_urls?: string[];
prompt?: string;
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
mode?: 'edit' | 'outpaint';
model: string;
append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */
@@ -254,6 +259,8 @@ export function listProductWatermarkJobs(productId: string, activeOnly = false,
export interface ExportImagesPayload {
title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
/** 生成结果导出时忽略图片分组,统一放在 ZIP 的商品根目录。 */
flat?: boolean;
}
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
@@ -321,6 +328,8 @@ export function deleteVideoJob(jobId: string) {
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
}
export function videoDownloadUrl(jobId: string) {
return `/api/video-jobs/${jobId}/download`;
/** 下载视频为 Blob,避免浏览器把 MP4 当作页面打开或预览。 */
export async function downloadVideo(jobId: string): Promise<Blob> {
const res = await apiClient.get(`/video-jobs/${jobId}/download`, { responseType: 'blob' });
return res.data as Blob;
}
+1 -1
View File
@@ -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/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"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./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/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/trialimagegrids.tsx","./src/pages/trial/trialinfopanel.tsx","./src/pages/trial/trialpage.tsx","./src/pages/trial/trialpricingpanel.tsx","./src/pages/trial/trialsuitepanel.tsx","./src/pages/trial/trialtaskcenter.tsx","./src/pages/trial/trialvideomaker.tsx","./src/pages/trial/trialwatermarksettings.tsx","./src/pages/trial/trialimagetypes.ts","./src/pricing/pricing.ts","./src/router/index.tsx","./src/services/ai.ts","./src/services/api.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"}