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
+4 -4
View File
@@ -56,11 +56,11 @@
## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认100,0-500)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineDiscountRate` 划线折扣率%(默认50,0-99.99)、
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。
> 划线价按「划线折扣率」计算:`划线价 = 销售价 ÷ (1 - 划线折扣率)`。例如折扣率 50%、销售价 ¥50 时,划线价为 ¥100。折扣率必须 ≥ 0 且 < 100%。
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
@@ -85,7 +85,7 @@
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
完全成本 = 进货价 + 物流费 + 平台总抽成
预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价
划线价¥ = 销售价 ÷ (1 - 划线折扣率);差额 = 划线价 - 销售价
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
```
@@ -103,7 +103,7 @@
// product.pricing(PriceInfoPanel 字段的超集,向后兼容)
{
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3,
"lineMultiplier": 100, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
"lineDiscountRate": 50, "fxRate": 11.5, "weightG": 600, "dims": { "l": 20, "w": 15, "h": 10 },
"logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63,
"fullCommission": 15.02, "totalCost": 67.14,
"sellingPriceCny": 77.51, "linePriceCny": 155.02,
@@ -47,11 +47,11 @@ export default defineBackground(() => {
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
if (msg?.action === 'fetchText') {
const headers: Record<string, string> = {};
if (msg.referer) headers['Referer'] = msg.referer;
fetch(msg.url, {
headers,
credentials: msg.credentials ? 'include' : 'omit',
// Referer 是受保护请求头,不能通过 headers 设置;使用 Fetch 的标准选项。
referrer: typeof msg.referer === 'string' ? msg.referer : undefined,
referrerPolicy: 'strict-origin-when-cross-origin',
})
.then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -300,6 +300,11 @@ const App: React.FC = () => {
}
setReporting(true);
try {
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。
const imagesToReport = selectedKeys.size > 0
? allImages.filter(img => selectedKeys.has(img.key))
: allImages;
const data = await send<{ product_id: string }>('reportProduct', {
reportBaseUrl: settings.reportBaseUrl,
payload: {
@@ -310,8 +315,7 @@ const App: React.FC = () => {
collectedAt: result.scannedAt,
},
texts: editedTexts(),
// 全量上报采集图(main/sku/detail/video),试算页需要完整素材
images: result.images.map(img => ({
images: imagesToReport.map(img => ({
groupKey: img.groupKey,
groupName: img.groupName,
variantName: img.variantName ?? null,
@@ -30,6 +30,7 @@ export interface State1688 {
videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[];
params: Array<{ key: string; value: string }>;
detailUrl?: string;
}
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
@@ -150,6 +151,13 @@ export function extract1688State(context: unknown): State1688 | null {
temp.topCategoryId ? String(temp.topCategoryId) : '',
].filter(Boolean);
// 图文详情位于独立 CDN,SSR 直接给出地址;无需点击标签或滚动页面。
const description = deepFind(context, 'description');
const candidateDetailUrl = description?.fields?.detailUrl ?? description?.detailUrl;
const detailUrl = typeof candidateDetailUrl === 'string' && /^https:\/\//.test(candidateDetailUrl)
? candidateDetailUrl
: undefined;
if (!title && mainImages.length === 0 && skus.length === 0) return null;
return {
@@ -164,5 +172,6 @@ export function extract1688State(context: unknown): State1688 | null {
videos,
skus,
params,
detailUrl,
};
}
@@ -0,0 +1,97 @@
/**
* 图文详情资源提取。接口可返回 HTML,也可返回 `var desc = '...'` 形式的脚本;
* 本模块只解析字符串和 HTML,绝不执行远端脚本。
*/
import { dedupeKey, pickBestFromSrcset, toOriginalUrl } from './url';
import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
const IMAGE_ATTRS = ['src', 'data-src', 'data-lazyload-src', 'data-original', 'data-lazy', 'data-ks-lazyload'];
function normalizeEscapedText(raw: string): string {
return raw
.replace(/\\u([0-9a-f]{4})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\x([0-9a-f]{2})/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
.replace(/\\\//g, '/');
}
function normalizeUrl(raw: string, baseUrl: string): string | null {
const value = raw.trim().replace(/^['"]|['"]$/g, '');
if (!value || /^data:|^javascript:|^blob:/i.test(value)) return null;
try {
const url = new URL(value, baseUrl);
return /^https?:$/i.test(url.protocol) ? url.href : null;
} catch {
return null;
}
}
function addUrl(out: string[], seen: Set<string>, raw: string | null, baseUrl: string): void {
if (!raw) return;
const url = normalizeUrl(raw, baseUrl);
if (!url || seen.has(url)) return;
seen.add(url);
out.push(url);
}
/** 从详情 HTML 或字符串化的详情脚本中提取图片地址。 */
export function extractDetailImageUrls(raw: string, baseUrl: string): string[] {
const urls: string[] = [];
const seen = new Set<string>();
const decoded = normalizeEscapedText(raw);
const doc = new DOMParser().parseFromString(decoded, 'text/html');
for (const el of Array.from(doc.querySelectorAll('img, source'))) {
for (const attr of IMAGE_ATTRS) addUrl(urls, seen, el.getAttribute(attr), baseUrl);
const srcset = el.getAttribute('srcset');
if (srcset) addUrl(urls, seen, pickBestFromSrcset(srcset), baseUrl);
}
for (const el of Array.from(doc.querySelectorAll<HTMLElement>('[style]'))) {
addUrl(urls, seen, el.style.backgroundImage.match(/url\(([^)]+)\)/i)?.[1] ?? null, baseUrl);
}
// 某些 1688 响应会把 HTML 包在 JS 字符串里;补扫图片 CDN 的 URL 字面量。
const candidates = decoded.match(/(?:https?:)?\/\/[^\s'"<>\\]+/g) ?? [];
for (const candidate of candidates) {
const url = normalizeUrl(candidate.replace(/[),;]+$/, ''), baseUrl);
if (!url) continue;
const hostIsImageCdn = /(?:alicdn|taobaocdn|tmallcdn)\.com$/i.test(new URL(url).hostname);
const hasImageExt = /\.(?:avif|bmp|gif|jpe?g|png|webp)(?:[?#]|$)/i.test(url);
if (hostIsImageCdn || hasImageExt) addUrl(urls, seen, url, baseUrl);
}
return urls;
}
/** 由 background service worker 跨域读取详情接口。 */
export function fetchDetailText(url: string, referer: string): Promise<string> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{ action: 'fetchText', url, referer, credentials: true },
(response: { ok?: boolean; text?: string; error?: string } | undefined) => {
const runtimeError = chrome.runtime.lastError;
if (runtimeError) return reject(new Error(runtimeError.message));
if (!response?.ok || typeof response.text !== 'string') {
return reject(new Error(response?.error || '详情接口未返回内容'));
}
resolve(response.text);
},
);
});
}
export function detailUrlsToMaterials(urls: string[], profile: SiteProfile): ImageMaterial[] {
const seen = new Set<string>();
const result: ImageMaterial[] = [];
for (const rawUrl of urls) {
const url = toOriginalUrl(rawUrl, profile.originalUrlRules);
const key = dedupeKey(url, profile.originalUrlRules);
if (seen.has(key)) continue;
seen.add(key);
result.push({
key: `detail-${String(result.length + 1).padStart(3, '0')}`,
groupKey: 'detail', groupName: '详情图', url, thumbUrl: rawUrl,
index: result.length, type: 'img',
});
}
return result;
}
+19
View File
@@ -53,6 +53,25 @@ export function queryAllDeep(selectors: string[]): Element[] {
return out;
}
/**
* 淘宝等页面会把图文详情放在延迟挂载的标签页中;仅滚动页面不会触发该标签的内容加载。
* 只匹配明确的详情标签,避免误点商品页中的普通链接或推荐内容。
*/
export async function activateDetailTab(): Promise<void> {
const labels = new Set(['图文详情', '商品详情', '详情']);
const candidates = Array.from(document.querySelectorAll<HTMLElement>('[role="tab"], button, [class*="tab"]'));
const tab = candidates.find((el) => {
const label = (el.textContent || '').replace(/\s+/g, ' ').trim();
const rect = el.getBoundingClientRect();
return labels.has(label) && rect.width > 0 && rect.height > 0 && el.getAttribute('aria-selected') !== 'true';
});
if (!tab) return;
tab.click();
// 给懒加载容器一次渲染机会,再由后续滚动加载实际图片。
await new Promise((resolve) => setTimeout(resolve, 600));
}
/**
* 自动滚动到页面底部,触发懒加载(详情图在页面尾部,不滚不加载)。
* 有界滚动:小步分段 + 随机延迟(模拟人工浏览节奏,避免"一滚到底"的机器人特征),
+86
View File
@@ -0,0 +1,86 @@
/** 小型 MD5 实现,仅用于淘宝公开 H5 接口的请求签名。 */
function add32(a: number, b: number): number { return (a + b) & 0xffffffff; }
function rol(num: number, cnt: number): number { return (num << cnt) | (num >>> (32 - cnt)); }
function cmn(q: number, a: number, b: number, x: number, s: number, t: number): number {
return add32(rol(add32(add32(a, q), add32(x, t)), s), b);
}
function ff(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a: number, b: number, c: number, d: number, x: number, s: number, t: number): number {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function cycle(state: number[], block: number[]): void {
let [a, b, c, d] = state;
a = ff(a, b, c, d, block[0], 7, -680876936); d = ff(d, a, b, c, block[1], 12, -389564586);
c = ff(c, d, a, b, block[2], 17, 606105819); b = ff(b, c, d, a, block[3], 22, -1044525330);
a = ff(a, b, c, d, block[4], 7, -176418897); d = ff(d, a, b, c, block[5], 12, 1200080426);
c = ff(c, d, a, b, block[6], 17, -1473231341); b = ff(b, c, d, a, block[7], 22, -45705983);
a = ff(a, b, c, d, block[8], 7, 1770035416); d = ff(d, a, b, c, block[9], 12, -1958414417);
c = ff(c, d, a, b, block[10], 17, -42063); b = ff(b, c, d, a, block[11], 22, -1990404162);
a = ff(a, b, c, d, block[12], 7, 1804603682); d = ff(d, a, b, c, block[13], 12, -40341101);
c = ff(c, d, a, b, block[14], 17, -1502002290); b = ff(b, c, d, a, block[15], 22, 1236535329);
a = gg(a, b, c, d, block[1], 5, -165796510); d = gg(d, a, b, c, block[6], 9, -1069501632);
c = gg(c, d, a, b, block[11], 14, 643717713); b = gg(b, c, d, a, block[0], 20, -373897302);
a = gg(a, b, c, d, block[5], 5, -701558691); d = gg(d, a, b, c, block[10], 9, 38016083);
c = gg(c, d, a, b, block[15], 14, -660478335); b = gg(b, c, d, a, block[4], 20, -405537848);
a = gg(a, b, c, d, block[9], 5, 568446438); d = gg(d, a, b, c, block[14], 9, -1019803690);
c = gg(c, d, a, b, block[3], 14, -187363961); b = gg(b, c, d, a, block[8], 20, 1163531501);
a = gg(a, b, c, d, block[13], 5, -1444681467); d = gg(d, a, b, c, block[2], 9, -51403784);
c = gg(c, d, a, b, block[7], 14, 1735328473); b = gg(b, c, d, a, block[12], 20, -1926607734);
a = hh(a, b, c, d, block[5], 4, -378558); d = hh(d, a, b, c, block[8], 11, -2022574463);
c = hh(c, d, a, b, block[11], 16, 1839030562); b = hh(b, c, d, a, block[14], 23, -35309556);
a = hh(a, b, c, d, block[1], 4, -1530992060); d = hh(d, a, b, c, block[4], 11, 1272893353);
c = hh(c, d, a, b, block[7], 16, -155497632); b = hh(b, c, d, a, block[10], 23, -1094730640);
a = hh(a, b, c, d, block[13], 4, 681279174); d = hh(d, a, b, c, block[0], 11, -358537222);
c = hh(c, d, a, b, block[3], 16, -722521979); b = hh(b, c, d, a, block[6], 23, 76029189);
a = hh(a, b, c, d, block[9], 4, -640364487); d = hh(d, a, b, c, block[12], 11, -421815835);
c = hh(c, d, a, b, block[15], 16, 530742520); b = hh(b, c, d, a, block[2], 23, -995338651);
a = ii(a, b, c, d, block[0], 6, -198630844); d = ii(d, a, b, c, block[7], 10, 1126891415);
c = ii(c, d, a, b, block[14], 15, -1416354905); b = ii(b, c, d, a, block[5], 21, -57434055);
a = ii(a, b, c, d, block[12], 6, 1700485571); d = ii(d, a, b, c, block[3], 10, -1894986606);
c = ii(c, d, a, b, block[10], 15, -1051523); b = ii(b, c, d, a, block[1], 21, -2054922799);
a = ii(a, b, c, d, block[8], 6, 1873313359); d = ii(d, a, b, c, block[15], 10, -30611744);
c = ii(c, d, a, b, block[6], 15, -1560198380); b = ii(b, c, d, a, block[13], 21, 1309151649);
a = ii(a, b, c, d, block[4], 6, -145523070); d = ii(d, a, b, c, block[11], 10, -1120210379);
c = ii(c, d, a, b, block[2], 15, 718787259); b = ii(b, c, d, a, block[9], 21, -343485551);
state[0] = add32(a, state[0]); state[1] = add32(b, state[1]);
state[2] = add32(c, state[2]); state[3] = add32(d, state[3]);
}
function block(input: string): number[] {
const out: number[] = [];
for (let i = 0; i < 64; i += 4) {
out[i >> 2] = input.charCodeAt(i) + (input.charCodeAt(i + 1) << 8)
+ (input.charCodeAt(i + 2) << 16) + (input.charCodeAt(i + 3) << 24);
}
return out;
}
/** 返回 UTF-8 字符串的 32 位小写 MD5。 */
export function md5(input: string): string {
const text = unescape(encodeURIComponent(input));
const state = [1732584193, -271733879, -1732584194, 271733878];
let i = 64;
for (; i <= text.length; i += 64) cycle(state, block(text.slice(i - 64, i)));
const tail = new Array<number>(16).fill(0);
const rest = text.slice(i - 64);
for (let j = 0; j < rest.length; j++) tail[j >> 2] |= rest.charCodeAt(j) << ((j % 4) << 3);
tail[rest.length >> 2] |= 0x80 << ((rest.length % 4) << 3);
if (rest.length > 55) cycle(state, tail);
tail.fill(0, rest.length > 55 ? 0 : 15);
tail[14] = text.length * 8;
cycle(state, tail);
return state.map((word) => {
let out = '';
for (let j = 0; j < 4; j++) out += ((word >> (j * 8)) & 0xff).toString(16).padStart(2, '0');
return out;
}).join('');
}
+1 -1
View File
@@ -83,7 +83,7 @@ export function finalize(
const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
if (images.length === 0) warnings.push('未扫描到任何图片/视频');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张,请滚动到页面底部后重新采集');
if ((stats.detail ?? 0) === 0) warnings.push('详情图为 0 张:页面未提供可读取的图文详情数据');
return {
platform: profile.id,
@@ -2,16 +2,16 @@
* 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑),
* 详情图改为滚动加载后由 DOM 采集补齐。
* ② description.detailUrl:直接读取图文详情,不操作用户页面
* ③ DOM 兜底 + 补充当前已挂载内容
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state';
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
@@ -20,6 +20,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
let st: State1688 | null = null;
const detailWarnings: string[] = [];
// ① 桥读 window.context
const keys = await readWindowKeys(['context']);
@@ -75,11 +76,22 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
});
});
source = 'state';
if (st.detailUrl) {
try {
const rawDetail = await fetchDetailText(st.detailUrl, location.href);
const detailImages = detailUrlsToMaterials(extractDetailImageUrls(rawDetail, st.detailUrl), profile);
primaryImages.push(...detailImages);
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
} catch (error) {
detailWarnings.push(`详情接口读取失败: ${error instanceof Error ? error.message : String(error)}`);
}
} else {
detailWarnings.push('页面未提供详情接口地址');
}
}
// ② DOM 兜底 + 补充(参数表 #productAttributes、详情图 #detail 在这里进结果)
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
// ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
@@ -90,6 +102,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source);
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -5,7 +5,7 @@
* ③ 站内页 JSON API(entrypoint-api.bx)
* ④ DOM data-widget 选择器兜底 + 详情图补充
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl, toThumbUrl } from '../url';
@@ -148,6 +148,7 @@ export async function scanOzon(profile: SiteProfile, itemId: string | null): Pro
// ── 路径④:DOM 采集(兜底 + 详情图补充)──
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await activateDetailTab();
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
@@ -3,15 +3,15 @@
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* (isolated world 读不到 window 变量,v1 直读是无效的)
* ② DOM 兜底 + 补充
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com),
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
* 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。
*/
import { autoScrollToBottom, waitForAny } from '../dom';
import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge } from '../taobao-state';
import { collectTaobaoDetailImages } from '../taobao-detail';
import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types';
@@ -19,6 +19,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom';
const detailWarnings: string[] = [];
// ① 桥读页面全局
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']);
@@ -31,9 +32,20 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
source = 'ssr';
}
// ② DOM 兜底 + 补充
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
// ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。
if (_itemId) {
try {
const detailImages = await collectTaobaoDetailImages(_itemId, profile);
primaryImages.push(...detailImages);
if (detailImages.length === 0) detailWarnings.push('详情接口未解析到图片');
} catch (error) {
detailWarnings.push('详情接口读取失败: ' + (error instanceof Error ? error.message : String(error)));
}
} else {
detailWarnings.push('未识别到商品 ID');
}
// ③ DOM 兜底:只读取首屏已挂载内容,不点击详情标签、不滚动页面。
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile);
@@ -44,6 +56,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
const texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, _itemId, texts, images, [], source);
if ((result.stats.detail ?? 0) === 0) result.warnings.push(...detailWarnings);
if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result;
}
@@ -139,9 +139,9 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
if (images.length === 0) {
warnings.push('未扫描到任何图片/视频');
}
// SSR 数据里没有详情图,需要 DOM 补充
// SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。
if (stats.detail === undefined) {
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集');
warnings.push('详情图需补充:页面未提供可读取的图文详情数据');
}
return {
@@ -0,0 +1,56 @@
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from './detail-images';
import { md5 } from './md5';
import type { ImageMaterial } from './image';
import type { SiteProfile } from '../profiles/types';
function getMtopToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)_m_h5_tk=([^;]+)/);
if (!match) return null;
try {
return decodeURIComponent(match[1]).split('_')[0] || null;
} catch {
return match[1].split('_')[0] || null;
}
}
function mtopUrl(itemId: string, token: string): string {
const now = Date.now().toString();
const current = new URL(location.href);
const data = JSON.stringify({
abbucket: current.searchParams.get('abbucket') || '',
id: itemId,
ns: current.searchParams.get('ns') || '',
spm: current.searchParams.get('spm') || '',
detail_v: '3.3.0',
preferWireless: 'true',
});
const host = current.hostname === 'detail.tmall.com' ? 'h5api.m.tmall.com' : 'h5api.m.taobao.com';
const url = new URL('https://' + host + '/h5/mtop.taobao.detail.getdesc/7.0/');
url.search = new URLSearchParams({
jsv: '2.7.0', appKey: '12574478', t: now, sign: md5(token + '&' + now + '&12574478&' + data),
api: 'mtop.taobao.detail.getdesc', v: '7.0', isSec: '0', ecode: '0',
AntiFlood: 'true', AntiCreep: 'true', H5Request: 'true', ttid: '2022@tmall_litepc_9.17.0',
type: 'json', dataType: 'json', data,
}).toString();
return url.href;
}
/** 无滚动读取淘宝/天猫图文详情。仅使用浏览器现有登录态,不会改动页面。 */
export async function collectTaobaoDetailImages(itemId: string, profile: SiteProfile): Promise<ImageMaterial[]> {
const token = getMtopToken();
if (!token) throw new Error('未找到淘宝登录态;请登录后重新采集');
const raw = await fetchDetailText(mtopUrl(itemId, token), location.href);
let response: any;
try {
response = JSON.parse(raw);
} catch {
throw new Error('详情接口返回了无效数据');
}
if (!response?.data?.components?.componentData) {
throw new Error(response?.ret?.[0] || '详情接口未返回图文详情');
}
const components = response.data.components.componentData;
const richText = components.desc_richtext_pc?.model?.text;
const source = typeof richText === 'string' ? richText : JSON.stringify(components);
return detailUrlsToMaterials(extractDetailImageUrls(source, location.href), profile);
}
+2
View File
@@ -21,6 +21,8 @@ export default defineConfig({
'https://detail.1688.com/*',
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
'https://h5api.m.taobao.com/*',
'https://h5api.m.tmall.com/*',
'https://*.alicdn.com/*',
// 1688 详情数据 CDN(description.detailUrl)
'https://itemcdn.tmall.com/*',
+5 -4
View File
@@ -2,8 +2,8 @@
平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传),
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。
采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可
请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。
"""
from __future__ import annotations
@@ -51,6 +51,7 @@ class ExportImageItem(BaseModel):
class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem] = Field(min_length=1)
flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录")
def _clean(name: str) -> str:
@@ -159,8 +160,8 @@ async def export_images(req: ExportImagesRequest):
filename = f"{stem}-{n}{ext}"
used.add(filename)
group = _clean(img.groupName) or "图片"
zf.writestr(f"{root}/{group}/{filename}", data)
archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}"
zf.writestr(archive_path, data)
if archive.tell() > settings.export_zip_max_bytes:
raise HTTPException(
status_code=413,
+46 -15
View File
@@ -9,6 +9,7 @@ watermark + services/prompts),任务持久化到数据库,由独立 Worker
from __future__ import annotations
import io
import re
import tempfile
import uuid
import zipfile
@@ -49,25 +50,39 @@ from services.suite_service import texts_to_raw, validate_model
router = APIRouter(prefix="/api", tags=["suite"])
_DIMENSION_PARAM_RE = re.compile(
r"尺寸|长宽高|长度|宽度|高度|厚度|深度|直径|dimension|length|width|height|depth|thickness|diameter",
re.IGNORECASE,
)
def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
"""将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。"""
"""将已填写的尺寸方向放入提示词上下文;未填写方向绝不传给绘制提示词。"""
if dimensions is None:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
values = (dimensions.length, dimensions.width, dimensions.height)
if not all(value is not None and value > 0 for value in values):
known_dimensions = {
key: value for key, value in {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
}.items() if value is not None and value > 0
}
if not known_dimensions:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
enriched = dict(raw)
enriched["dimensions"] = {
"length": dimensions.length,
"width": dimensions.width,
"height": dimensions.height,
"unit": dimensions.unit,
}
# 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 L×W×H,
# 若继续带进 prompt,模型会把未填写的方向也画出来。
params = raw.get("params") or []
if isinstance(params, list):
enriched["params"] = [
pair for pair in params
if isinstance(pair, dict) and not _DIMENSION_PARAM_RE.search(str(pair.get("key") or ""))
]
enriched["dimensions"] = {**known_dimensions, "unit": dimensions.unit}
return enriched
@@ -315,7 +330,19 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from exc
# 单图编辑优先级高于套图;prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
# 单图编辑优先级高于套图;支持多张参考底图,保持传入顺序并去重。
ref_urls = list(dict.fromkeys(url for url in [*req.image_urls, req.image_url] if url))
if not ref_urls:
raise HTTPException(status_code=400, detail="请至少选择一张参考底图")
if req.mode == "outpaint":
if provider_name != "pcore":
raise HTTPException(status_code=400, detail="仅扩图当前仅支持 Pcore gpt-image-2")
if len(ref_urls) != 1:
raise HTTPException(status_code=400, detail="仅扩图只能选择一张参考底图")
if req.generate_size_chart:
raise HTTPException(status_code=400, detail="仅扩图不能同时生成尺寸标注图")
# prompt_extra 清空展示文案,避免中文 UI 文案进入模型 prompt。
is_size_chart = bool(req.generate_size_chart)
task = await create_image_job(
product_id=product_id,
@@ -326,20 +353,24 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
platform="ozon",
lang=spec["lang"],
ratio=spec["ratio"],
style_set=5,
# 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5,
request_payload={
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": None,
"requirements": req.prompt,
"operation": req.mode,
# 用户写了要求时,这一轮只执行其要求,不再拼营销图、默认风格或默认俄文文案。
"requirements_exclusive": bool(req.mode == "edit" and not is_size_chart and req.prompt.strip()),
"watermark": req.watermark.model_dump() if req.watermark else None,
"append": bool(req.append and product_id),
"after_asset_id": req.after_asset_id,
"ref_images": [{"url": req.image_url, "group_key": "main", "variant_name": None}],
"ref_images": [{"url": url, "group_key": "main", "variant_name": None} for url in ref_urls],
},
item_payloads=[{
"position": 0,
"kind": "size_chart" if is_size_chart else "custom",
"title": "尺寸标注图" if is_size_chart else "AI生图",
"title": "尺寸标注图" if is_size_chart else ("仅扩图" if req.mode == "outpaint" else "AI生图"),
"detail": "",
"prompt_hint": "",
"variant_name": None,
View File
+8 -3
View File
@@ -77,7 +77,7 @@ class WatermarkOptions(BaseModel):
class ProductDimensions(BaseModel):
"""商品长宽高;尺寸图只会使用完整的三项数据。"""
"""商品长宽高;尺寸图仅标注实际提供的方向。"""
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: float | None = Field(default=None, gt=0, description="宽度(按 unit 单位)")
@@ -177,10 +177,15 @@ class SuiteOut(BaseModel):
class ImageEditSingleRequest(BaseModel):
"""POST /api/suite/image-edit:单张 AI 生图(试算页每张图的「AI 生图」入口)。"""
"""POST /api/suite/image-edit:单张 AI 生图,可携带多张参考底图。"""
product_id: str | None = Field(default=None, description="append=true 时回写到该商品的 generated 组")
image_url: str = Field(..., description="原图(stored_url 或 source_url,远程由服务端带 Referer 代下)")
image_url: str | None = Field(default=None, description="兼容旧客户端的单张原图 URL")
image_urls: list[str] = Field(default_factory=list, description="选中的参考底图 URL,可多张")
prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
mode: Literal["edit", "outpaint"] = Field(
default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
)
model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field(
+80 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio
import base64
import io
import logging
import mimetypes
import re
@@ -17,6 +18,7 @@ from dataclasses import dataclass
from typing import Awaitable, Callable
import httpx
from PIL import Image, ImageOps
from config import get_settings
from services.prompts import build_context, build_prompt, type_name
@@ -84,8 +86,8 @@ def _image_size(provider: str, ratio: str, is_wan: bool = True, model: str = "")
# gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048"
if provider == "pcore":
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4)
return "1024x1536" if ratio == "3:4" else "1024x1024"
# 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
return "1152x1536" if ratio == "3:4" else "1024x1024"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"):
@@ -141,6 +143,75 @@ async def _resolve_ref(url: str) -> str:
return _bytes_to_data_uri(data, mime)
@dataclass(frozen=True)
class OutpaintCanvas:
"""用于受限扩图的预处理结果。
``image_data_url`` 与 ``mask_data_url`` 尺寸严格一致:蒙版不透明的区域是原图,
透明区域才允许供应商生成。最终仍会把 ``original`` 覆盖回结果,防止供应商在
蒙版区域有轻微重绘。
"""
image_data_url: str
mask_data_url: str
original: Image.Image
box: tuple[int, int, int, int]
size: tuple[int, int]
def restore_original(self, generated: bytes) -> bytes:
"""恢复原图区域,确保输出中原图像素不受模型影响。"""
with Image.open(io.BytesIO(generated)) as result:
result = ImageOps.exif_transpose(result).convert("RGBA")
if result.size != self.size:
# 供应商偶发不遵从 size 时仍输出固定 3:4;随后会精确覆盖原图区域。
result = ImageOps.fit(result, self.size, method=Image.Resampling.LANCZOS)
result.alpha_composite(self.original, self.box[:2])
out = io.BytesIO()
result.save(out, format="PNG")
return out.getvalue()
def _png_data_url(image: Image.Image) -> str:
out = io.BytesIO()
image.save(out, format="PNG")
return _bytes_to_data_uri(out.getvalue(), "image/png")
async def prepare_outpaint_canvas(url: str, size: str) -> OutpaintCanvas:
"""把单张参考图置于目标画布,并生成仅开放新增边缘的 PNG alpha 蒙版。"""
try:
target_width, target_height = (int(value) for value in size.lower().split("x", 1))
except (TypeError, ValueError) as exc:
raise ValueError(f"仅扩图需要合法的宽高尺寸,收到: {size}") from exc
if target_width <= 0 or target_height <= 0:
raise ValueError(f"仅扩图需要正数宽高尺寸,收到: {size}")
data, _ = await _resolve_ref_bytes(url)
with Image.open(io.BytesIO(data)) as opened:
source = ImageOps.exif_transpose(opened).convert("RGBA")
if source.width <= 0 or source.height <= 0:
raise ValueError("参考底图尺寸无效")
scale = min(target_width / source.width, target_height / source.height)
source_width = max(1, round(source.width * scale))
source_height = max(1, round(source.height * scale))
original = source.resize((source_width, source_height), Image.Resampling.LANCZOS)
x = (target_width - original.width) // 2
y = (target_height - original.height) // 2
canvas = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
canvas.alpha_composite(original, (x, y))
# OpenAI/GPT Image 兼容蒙版约定:透明像素是可编辑区域;原图区域不透明。
mask = Image.new("RGBA", (target_width, target_height), (0, 0, 0, 0))
mask.paste((255, 255, 255, 255), (x, y, x + original.width, y + original.height))
return OutpaintCanvas(
image_data_url=_png_data_url(canvas),
mask_data_url=_png_data_url(mask),
original=original,
box=(x, y, x + original.width, y + original.height),
size=(target_width, target_height),
)
# ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes:
@@ -562,6 +633,7 @@ async def advance_generation(
model: str,
provider_task_id: str | None = None,
provider_task_kind: str | None = None,
edit_mask: str | None = None,
) -> GenerationAdvance:
"""推进一次图片生成,不在本函数内无限轮询。
@@ -569,6 +641,8 @@ async def advance_generation(
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
"""
settings = get_settings()
if edit_mask and provider != "pcore":
raise RuntimeError("带蒙版的仅扩图当前仅支持 Pcore gpt-image-2")
if provider == "doubao":
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model))
@@ -650,6 +724,10 @@ async def advance_generation(
body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images:
body["images"] = [{"image_url": await _resolve_ref(item)} for item in ref_images]
if edit_mask:
# Pcore 的图片参数使用 JSON {image_url};蒙版沿用相同的 OpenAI 兼容对象形式。
# 透明区域允许编辑,不透明区域保留。若供应商轻微偏移,Worker 会二次覆盖原图。
body["mask"] = {"image_url": edit_mask}
response = await client.post(
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
)
+46 -8
View File
@@ -10,7 +10,7 @@ from config import get_settings
from db import get_session_factory
from models import ProductAsset
from services.image_assets import get_image_asset_service
from services.generator import _image_size, _refs_for_job, advance_generation
from services.generator import OutpaintCanvas, _image_size, _refs_for_job, advance_generation, prepare_outpaint_canvas
from services.jobs import (
ClaimedItem,
claim_next_item,
@@ -22,6 +22,7 @@ from services.jobs import (
recover_expired_leases,
)
from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block
from services.storage import get_storage
from services.suite_service import append_generated_asset
from services.tasks import TaskImage
@@ -152,6 +153,22 @@ async def _process_slideshow_video_item(claim: ClaimedItem) -> None:
await mark_item_failed(claim.item_id, str(exc))
def _outpaint_prompt(requirements: str | None) -> str:
"""仅扩图不复用套图提示词,避免默认风格、营销构图或文字污染边缘生成。"""
requested = (requirements or "").strip()
if not requested:
requested = "Continue the existing surroundings naturally and seamlessly."
return "\n\n".join([
requirements_block(requested),
"OUTPAINT ONLY: Image 1 contains an original photo placed on a 3:4 canvas. Generate pixels only "
"in the transparent masked margins outside the original photo. Do not crop, move, resize, redraw, "
"retouch, recolor, relight, or otherwise alter any pixel of the original photo.",
"Do not add marketing layouts, product captions, overlays, props, or visible text unless the STRICT "
"REQUIREMENTS explicitly ask for them. Make the new margins blend naturally with the original edges.",
"FINAL CHECK: output exactly 3:4 and change only the newly expanded margins.",
])
async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch":
@@ -174,18 +191,34 @@ async def process_item(claim: ClaimedItem) -> None:
"pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model)
try:
context = build_context(payload.get("context") or {}, fallback_name="")
prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
)
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
operation = payload.get("operation", "edit")
target_size = _image_size(provider, claim.ratio, is_wan=provider == "tongyi" and model.lower().startswith("wan"), model=model)
edit_mask: str | None = None
outpaint: OutpaintCanvas | None = None
if operation == "outpaint":
if len(refs) != 1:
raise RuntimeError("仅扩图必须且只能使用一张参考底图")
prompt = _outpaint_prompt(payload.get("requirements"))
# 首次提交时才构造图片和蒙版;之后轮询不再向供应商重复上传。
if not claim.provider_task_id:
outpaint = await prepare_outpaint_canvas(refs[0], target_size)
refs = [outpaint.image_data_url]
edit_mask = outpaint.mask_data_url
else:
context = build_context(payload.get("context") or {}, fallback_name="")
prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang,
extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
requirements_exclusive=bool(payload.get("requirements_exclusive")),
)
is_wan = provider == "tongyi" and model.lower().startswith("wan")
result = await advance_generation(
provider, prompt, refs,
size=_image_size(provider, claim.ratio, is_wan=is_wan, model=model), model=model,
size=target_size, model=model,
provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
edit_mask=edit_mask,
)
if result.data is None:
if not result.provider_task_id or not result.provider_task_kind:
@@ -203,6 +236,11 @@ async def process_item(claim: ClaimedItem) -> None:
await mark_item_cancelled(claim.item_id)
return
if operation == "outpaint":
# 异步任务完成时本轮未必创建过画布,按同一源图与尺寸确定性重建后覆盖原图区域。
outpaint = outpaint or await prepare_outpaint_canvas(refs[0], target_size)
result = type(result)(data=outpaint.restore_original(result.data))
watermark = payload.get("watermark") or {}
position = int(job_item.get("position", 0))
if watermark.get("enabled"):
+4 -4
View File
@@ -56,11 +56,11 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的任一方向尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 1 张,展示关键细节、材质或结构;通常 1-2 张,每张聚焦一个卖点。
5. 尺寸标注图(kind=size_chart):参数含长宽高/尺寸时必须出 1 张;无可靠尺寸参数时不要虚构尺寸。
5. 尺寸标注图(kind=size_chart):参数含任一可靠尺寸时必须出 1 张;只标注已知方向,无可靠尺寸参数时不要虚构尺寸。
6. SKU 合集图(kind=sku_collection):仅在 SKU >1 且确有必要展示差异时出 1 张,优先级低于上述四类。
7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。
@@ -124,9 +124,9 @@ def _normalize_items(raw_items: list, sku_variants: list[str]) -> list[dict]:
def _has_dimensions(product_info: dict) -> bool:
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。"""
"""有任一可靠尺寸即可规划尺寸图;未给出的方向不允许模型编造。"""
dimensions = product_info.get("dimensions") or {}
if isinstance(dimensions, dict) and all(dimensions.get(key) is not None for key in ("length", "width", "height")):
if isinstance(dimensions, dict) and any(dimensions.get(key) is not None for key in ("length", "width", "height")):
return True
for param in product_info.get("params") or []:
if not isinstance(param, dict):
+2 -1
View File
@@ -36,10 +36,11 @@ def prompt_family(provider: str, model: str | None) -> str:
def build_prompt(provider: str, model: str | None, type_id: str, ctx: dict, style_set: int,
lang: str, extra: dict | None = None, style_prompt: str | None = None,
requirements: str | None = None) -> str:
requirements: str | None = None, requirements_exclusive: bool = False) -> str:
"""按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
return module.build_prompt(
type_id, ctx, style_set, lang,
extra=extra, style_prompt=style_prompt, requirements=requirements,
requirements_exclusive=requirements_exclusive,
)
+14 -3
View File
@@ -96,12 +96,13 @@ def _prompt_ecommerce_detail(ctx: dict, style: dict, lang: str) -> str:
def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views "
f"on light background, with thin measurement annotation lines (arrows) marking length, width and height, "
f"on light background, with thin measurement annotation lines (arrows) only for supplied dimensions, "
f"measurement values rendered next to each line"
+ (f" (known specs: {dims})" if dims else "")
+ f", small caption row, precise technical drawing aesthetic. {style['tone']}. "
+ f". {annotation} Small caption row, precise technical drawing aesthetic. {style['tone']}. "
f"{TEXT_RENDER[lang]} {QUALITY} {PRODUCT_REF_LOCK}"
)
@@ -136,7 +137,8 @@ _PROMPT_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的完整生图 prompt(主体参考语义)。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需,
@@ -145,6 +147,15 @@ def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict
requirements: 生图要求(最高优先级,强制约束),置于 prompt 最前面,
声明覆盖一切冲突指令,用户可在此输入强制要求。
"""
if requirements_exclusive:
return " ".join([
requirements_block(requirements),
"Execute only the strict requirements above. Do not add marketing layouts, captions, overlays, "
"props, default studio styling, or visible text unless explicitly requested.",
"Preserve the reference image exactly unless the strict requirements explicitly request a "
"specific change. When they do, make only that requested change and preserve everything else.",
"Do not make any change that was not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
if type_id == "custom":
+23 -5
View File
@@ -134,11 +134,28 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
break
dimensions = raw.get("dimensions") or {}
dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else ()
dimensions_line = ""
if len(dimension_values) == 3 and all(value is not None for value in dimension_values):
unit = dimensions.get("unit") or "mm"
dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}"
known_dimensions: list[tuple[str, object]] = []
if isinstance(dimensions, dict):
known_dimensions = [
(label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
if dimensions.get(key) is not None
]
unit = (dimensions.get("unit") or "mm") if isinstance(dimensions, dict) else "mm"
dimensions_line = "; ".join(f"{label}: {value} {unit}" for label, value in known_dimensions)
known_labels = [label for label, _ in known_dimensions]
missing_labels = [label for label in ("length", "width", "height") if label not in known_labels]
dimension_annotation_instruction = (
"STRICT MEASUREMENT OUTPUT RULE: render exactly "
+ str(len(known_labels))
+ " measurement arrows and labels, only for: "
+ ", ".join(known_labels)
+ ". The only permitted dimension values are: "
+ dimensions_line
+ ". Do not infer product measurements. Do not draw, label, or show an arrow for: "
+ ", ".join(missing_labels or ["any additional direction"])
+ ", depth, thickness, diameter, or any other unlisted dimension."
if known_dimensions else ""
)
params_line = "; ".join(
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8]
)
@@ -158,6 +175,7 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
dimensions_line,
))),
"price": raw.get("price") or "",
"dimension_annotation_instruction": dimension_annotation_instruction,
}
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str:
+15 -3
View File
@@ -98,12 +98,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
"Create a size chart: the unchanged product in clean front and side views on a light background, "
"thin measurement annotation lines (arrows) marking length, width and height with values beside "
"thin measurement annotation lines (arrows) only for supplied dimensions with values beside "
"each line"
+ (f" (known specs: {dims})" if dims else "")
+ ", small caption row, precise technical-drawing aesthetic."
+ f". {annotation} Small caption row, precise technical-drawing aesthetic."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
@@ -128,8 +129,19 @@ _TASK_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的 prompt:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。"""
if requirements_exclusive:
return "\n\n".join([
requirements_block(requirements),
"Edit the first image only as required above. Do not add marketing layouts, captions, overlays, "
"props, default studio styling, or visible text unless explicitly requested above.",
"PROTECTION: preserve the first image exactly unless the strict requirements explicitly request "
"a specific change. When they do, make only that requested change and preserve everything else.",
"Final check: follow the strict requirements over every default behavior; do not make changes "
"that were not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
+18 -4
View File
@@ -129,12 +129,13 @@ def _task_ecommerce_detail(ctx: dict, lang: str) -> str:
def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return (
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light "
"background; overlay thin technical annotation lines (arrows) marking length, width and height "
"with measurement values rendered beside each line"
"background; overlay thin technical annotation lines (arrows) only for the supplied dimensions, "
"with each measurement value rendered beside its line"
+ (f" (known specs: {dims})" if dims else "")
+ "; precise technical-drawing aesthetic, small caption row."
+ f". {annotation}; precise technical-drawing aesthetic, small caption row."
)
def _task_sku_collection(ctx: dict, lang: str) -> str:
@@ -160,8 +161,21 @@ _TASK_BUILDERS = {
def build_prompt(type_id: str, ctx: dict, style_set: int, lang: str, extra: dict | None = None,
style_prompt: str | None = None, requirements: str | None = None) -> str:
style_prompt: str | None = None, requirements: str | None = None,
requirements_exclusive: bool = False) -> str:
"""构造指定图类型的 edits 语义 prompt:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。"""
if requirements_exclusive:
# 单张 AI 编辑:用户要求是唯一的画面意图,绝不能被套图的营销图/棚拍/俄文文案覆盖。
return "\n\n".join([
requirements_block(requirements),
"TASK: Edit Image 1 only according to the STRICT REQUIREMENTS above. Do not add marketing "
"layouts, captions, overlays, props, default studio styling, or any visible text unless the "
"STRICT REQUIREMENTS explicitly ask for them.",
"PROTECTION: preserve Image 1 exactly unless the STRICT REQUIREMENTS explicitly request a "
"specific change. When they do, make only that requested change and preserve everything else.",
"FINAL CHECK: follow the STRICT REQUIREMENTS over every default behavior. Preserve the product "
"and do not make any change that is not explicitly required.",
])
style = resolve_style(style_set, style_prompt)
extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip()
+22 -12
View File
@@ -58,11 +58,16 @@ async def _run_process(
should_cancel: Callable[[], Awaitable[bool]],
timeout_seconds: int,
) -> None:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
try:
process = await asyncio.create_subprocess_exec(
*args,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频合成工具「{args[0]}」。请安装 FFmpeg,并配置 VIDEO_FFMPEG_PATH 为其可执行文件的绝对路径。"
) from exc
try:
waited = 0.0
while process.returncode is None:
@@ -91,13 +96,18 @@ async def _run_process(
async def _probe(path: Path) -> dict:
settings = get_settings()
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
"-of", "json", str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
"-of", "json", str(path),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频校验工具「{settings.video_ffprobe_path}」。请安装 FFmpeg,并配置 VIDEO_FFPROBE_PATH 为其可执行文件的绝对路径。"
) from exc
stdout, stderr = await process.communicate()
if process.returncode != 0:
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
+21 -1
View File
@@ -1,8 +1,10 @@
"""单图导出响应头回归测试。"""
from __future__ import annotations
import io
import sys
import unittest
import zipfile
from pathlib import Path
from unittest.mock import AsyncMock, patch
@@ -10,7 +12,7 @@ SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.export import ExportImageItem, export_image
from api.export import ExportImageItem, ExportImagesRequest, export_image, export_images
class ExportImageTests(unittest.IsolatedAsyncioTestCase):
@@ -27,6 +29,24 @@ class ExportImageTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('filename="_____-__.png"', disposition)
self.assertIn("filename*=UTF-8''%E5%B0%BA%E5%AF%B8%E6%A0%87%E6%B3%A8%E5%9B%BE-%E8%93%9D%E8%89%B2.png", disposition)
async def test_flat_export_places_generated_images_in_product_root(self) -> None:
request = ExportImagesRequest(
title="商品",
flat=True,
images=[
ExportImageItem(url="https://example.com/single.png", groupName="单张生图", key="single"),
ExportImageItem(url="https://example.com/suite.png", groupName="套图结果", key="suite"),
],
)
with patch("api.export._download", new=AsyncMock(return_value=(b"image", "image/png"))):
response = await export_images(request)
payload = b"".join([chunk async for chunk in response.body_iterator])
self.assertEqual(
sorted(zipfile.ZipFile(io.BytesIO(payload)).namelist()),
["商品/single.png", "商品/suite.png"],
)
if __name__ == "__main__":
unittest.main()
+22
View File
@@ -0,0 +1,22 @@
"""平台目标比例到供应商尺寸参数的映射。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import _image_size
class ImageSizeTests(unittest.TestCase):
def test_pcore_ozon_size_is_exactly_three_to_four(self) -> None:
width, height = map(int, _image_size("pcore", "3:4").split("x"))
self.assertEqual(width * 4, height * 3)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,70 @@
"""仅扩图的像素保护与单张 AI 编辑提示词回归测试。"""
from __future__ import annotations
import asyncio
import base64
import io
import sys
import unittest
from pathlib import Path
from PIL import Image
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from services.generator import prepare_outpaint_canvas
from services.prompts import build_prompt
def _data_url(image: Image.Image) -> str:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode()
class OutpaintTests(unittest.TestCase):
def test_outpaint_restores_original_region_after_provider_response(self) -> None:
original = Image.new("RGB", (400, 400), (220, 20, 60))
canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536"))
self.assertEqual(canvas.size, (1152, 1536))
self.assertEqual(canvas.box, (0, 192, 1152, 1344))
# 模拟供应商把整张图都重绘成蓝色;服务端合成后中心原图必须仍为红色。
generated = Image.new("RGB", canvas.size, (0, 0, 255))
restored = Image.open(io.BytesIO(canvas.restore_original(_png_bytes(generated)))).convert("RGB")
self.assertEqual(restored.getpixel((576, 768)), (220, 20, 60))
self.assertEqual(restored.getpixel((576, 80)), (0, 0, 255))
def test_expanded_margin_is_transparent_in_mask(self) -> None:
original = Image.new("RGB", (400, 400), (1, 2, 3))
canvas = asyncio.run(prepare_outpaint_canvas(_data_url(original), "1152x1536"))
mask_bytes = base64.b64decode(canvas.mask_data_url.split(",", 1)[1])
mask = Image.open(io.BytesIO(mask_bytes)).convert("RGBA")
self.assertEqual(mask.getpixel((576, 80))[3], 0)
self.assertEqual(mask.getpixel((576, 768))[3], 255)
def _png_bytes(image: Image.Image) -> bytes:
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
class ExclusiveEditPromptTests(unittest.TestCase):
def test_user_edit_requirements_do_not_inherit_marketing_or_light_studio_prompt(self) -> None:
prompt = build_prompt(
"pcore", "gpt-image-2", "custom", {"title": "product"}, 5, "ru",
requirements="背景必须是纯黑色,其他不要改变",
requirements_exclusive=True,
)
self.assertIn("背景必须是纯黑色", prompt)
self.assertNotIn("e-commerce marketing image", prompt)
self.assertNotIn("plain light background", prompt)
self.assertNotIn("Render concise Russian marketing text", prompt)
if __name__ == "__main__":
unittest.main()
+44
View File
@@ -0,0 +1,44 @@
"""部分尺寸的尺寸标注图:仅传入已知方向,绝不补造缺失方向。"""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
SERVER_DIR = Path(__file__).resolve().parents[1]
if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR))
from api.suite import _with_dimensions
from schemas.suite import ProductDimensions
from services.prompts.common import build_context
from services.prompts.gpt import _task_size_chart
class PartialDimensionsTests(unittest.TestCase):
def test_only_known_direction_is_passed_to_prompt_context(self) -> None:
raw = _with_dimensions(
{"title": "测试商品", "params": [
{"key": "产品尺寸", "value": "12.5×8×4 cm"},
{"key": "材质", "value": "棉"},
]},
ProductDimensions(width=8, height=4, unit="cm"),
required=True,
)
self.assertEqual(raw["dimensions"], {"width": 8, "height": 4, "unit": "cm"})
self.assertEqual(raw["params"], [{"key": "材质", "value": "棉"}])
context = build_context(raw)
prompt = _task_size_chart(context, "en")
self.assertIn("width: 8.0 cm; height: 4.0 cm", prompt)
self.assertIn("render exactly 2 measurement arrows and labels, only for: width, height", prompt)
self.assertIn("Do not draw, label, or show an arrow for: length", prompt)
self.assertNotIn("12.5×8×4", prompt)
def test_size_chart_still_requires_at_least_one_dimension(self) -> None:
with self.assertRaisesRegex(Exception, "至少需要填写一项"):
_with_dimensions({"title": "测试商品"}, ProductDimensions(unit="mm"), required=True)
if __name__ == "__main__":
unittest.main()
+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"}