feat: 试用过程中的细节优化
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 且 < 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}
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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 '售价较高,建议选高等级物流';
|
||||
|
||||
@@ -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 @@
|
||||
{"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"}
|
||||
Reference in New Issue
Block a user