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 移植,单位口径不变) ## 4. 02 价格试算(公式从 web/js/app.js 移植,单位口径不变)
输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、 输入参数(存 `product.pricing` JSON):`purchasePrice` 进货价¥(默认30)、`profitRate` 净利率%(默认100)、
`tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineMultiplier` 划线价倍数%(默认100,0-500)、 `tdPrice` 贴单费¥(默认3)、`logisticsLevel` low/high/high2(默认 low)、`lineDiscountRate` 划线折扣率%(默认50,0-99.99)、
`fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。 `fxRate` 汇率(`product.fx_rate` 快照,无则 `GET /api/fx` 拉取)。重量/尺寸在左列顶部维护(落 product 包装字段,变化即重算)。
> 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解, > 划线价口径(v2.1 调整):原 v1 的「预留折扣空间」(划线价 = 销售价 ÷ (1-折扣%))在 >95% 区间发散且 ≥100% 无解,
> 改为「划线价倍数」:`划线价 = 销售价 × (1 + 倍数%)`。倍数 100% → 划线价 = 2× 销售价;50% → 1.5×。线性、全程可算。 > 划线价按「划线折扣率」计算:`划线价 = 销售价 ÷ (1 - 划线折扣率)`。例如折扣率 50%、销售价 ¥50 时,划线价为 ¥100。折扣率必须 ≥ 0 且 < 100%。
**物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`): **物流费**(`baseLogisticsFee` 已在 `studio/src/pricing/pricing.ts`):
@@ -85,7 +85,7 @@
平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用 平台佣金 = 销售价 × 12%(low)/ 18%(其余) ← 展示用
平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费 平台总抽成 = 销售价 × (1 - 净得率) = 15.5% / 21.5% ← 含佣金及约3.5%其它费
完全成本 = 进货价 + 物流费 + 平台总抽成 完全成本 = 进货价 + 物流费 + 平台总抽成
预留后¥ = 销售价 × (1 + 划线价倍数%)(即划线价);差额 = 划线价 - 销售价 划线价¥ = 销售价 ÷ (1 - 划线折扣率);差额 = 划线价 - 销售价
销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率 销售价₽ = 销售价¥ × 汇率;划线₽ = 划线价¥ × 汇率
``` ```
@@ -103,7 +103,7 @@
// product.pricing(PriceInfoPanel 字段的超集,向后兼容) // product.pricing(PriceInfoPanel 字段的超集,向后兼容)
{ {
"purchasePrice": 30, "profitRate": 100, "logisticsLevel": "low", "tdPrice": 3, "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, "logisticsFee": 22.12, "receivedPrice": 60, "profitPrice": 30, "commission": 11.63,
"fullCommission": 15.02, "totalCost": 67.14, "fullCommission": 15.02, "totalCost": 67.14,
"sellingPriceCny": 77.51, "linePriceCny": 155.02, "sellingPriceCny": 77.51, "linePriceCny": 155.02,
@@ -47,11 +47,11 @@ export default defineBackground(() => {
// 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API) // 通用文本代理:采集引擎拉跨域资源(1688 详情 CDN / mtop API)
if (msg?.action === 'fetchText') { if (msg?.action === 'fetchText') {
const headers: Record<string, string> = {};
if (msg.referer) headers['Referer'] = msg.referer;
fetch(msg.url, { fetch(msg.url, {
headers,
credentials: msg.credentials ? 'include' : 'omit', 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) => { .then(async (res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -300,6 +300,11 @@ const App: React.FC = () => {
} }
setReporting(true); setReporting(true);
try { try {
// 上报遵循采集图片区的勾选状态:勾选了就只传勾选项;未勾选则全量传。
// 避免详情图过多时超过服务端的单次素材上限,同时保留“全不选 = 全量”的便捷行为。
const imagesToReport = selectedKeys.size > 0
? allImages.filter(img => selectedKeys.has(img.key))
: allImages;
const data = await send<{ product_id: string }>('reportProduct', { const data = await send<{ product_id: string }>('reportProduct', {
reportBaseUrl: settings.reportBaseUrl, reportBaseUrl: settings.reportBaseUrl,
payload: { payload: {
@@ -310,8 +315,7 @@ const App: React.FC = () => {
collectedAt: result.scannedAt, collectedAt: result.scannedAt,
}, },
texts: editedTexts(), texts: editedTexts(),
// 全量上报采集图(main/sku/detail/video),试算页需要完整素材 images: imagesToReport.map(img => ({
images: result.images.map(img => ({
groupKey: img.groupKey, groupKey: img.groupKey,
groupName: img.groupName, groupName: img.groupName,
variantName: img.variantName ?? null, variantName: img.variantName ?? null,
@@ -30,6 +30,7 @@ export interface State1688 {
videos: Array<{ url: string; cover?: string }>; videos: Array<{ url: string; cover?: string }>;
skus: Sku1688[]; skus: Sku1688[];
params: Array<{ key: string; value: string }>; params: Array<{ key: string; value: string }>;
detailUrl?: string;
} }
/** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */ /** 深度查找指定键(BFS + 访问标记 + 节点数上限,防大对象拖死) */
@@ -150,6 +151,13 @@ export function extract1688State(context: unknown): State1688 | null {
temp.topCategoryId ? String(temp.topCategoryId) : '', temp.topCategoryId ? String(temp.topCategoryId) : '',
].filter(Boolean); ].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; if (!title && mainImages.length === 0 && skus.length === 0) return null;
return { return {
@@ -164,5 +172,6 @@ export function extract1688State(context: unknown): State1688 | null {
videos, videos,
skus, skus,
params, 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; 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[] = []; const warnings: string[] = [];
if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题'); if (!texts.some((t) => t.kind === 'title')) warnings.push('未采集到标题');
if (images.length === 0) 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 { return {
platform: profile.id, platform: profile.id,
@@ -2,16 +2,16 @@
* 1688 采集编排(平台文件): * 1688 采集编排(平台文件):
* ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径 * ① MAIN world 桥读 window.context(模块化 SSR 状态)★主路径
* —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺 * —— 主图 / SKU 全规格图 / 价格区间 / SKU 级价格库存 / 每 SKU 长宽高重量 / 销量 / 店铺
* ② DOM 兜底 + 补充:#productAttributes 参数表(antd Descriptions)、#detail 详情图 * ② description.detailUrl:直接读取图文详情,不操作用户页面
* 说明:不再直调 description.detailUrl 数据端点(与淘宝 mtop 同样的风控考虑), * ③ DOM 兜底 + 补充当前已挂载内容
* 详情图改为滚动加载后由 DOM 采集补齐。
*/ */
import { autoScrollToBottom, waitForAny } from '../dom'; import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image'; import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl } from '../url'; import { toOriginalUrl } from '../url';
import { readWindowKeys } from '../../bridge/read-window'; import { readWindowKeys } from '../../bridge/read-window';
import { extract1688State, type State1688 } from '../1688-state'; import { extract1688State, type State1688 } from '../1688-state';
import { detailUrlsToMaterials, extractDetailImageUrls, fetchDetailText } from '../detail-images';
import { finalize, mergeImages, type ScanResult } from '../merge'; import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types'; import type { SiteProfile } from '../../profiles/types';
@@ -20,6 +20,7 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
let primaryImages: ImageMaterial[] = []; let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom'; let source: ScanResult['source'] = 'dom';
let st: State1688 | null = null; let st: State1688 | null = null;
const detailWarnings: string[] = [];
// ① 桥读 window.context // ① 桥读 window.context
const keys = await readWindowKeys(['context']); const keys = await readWindowKeys(['context']);
@@ -75,11 +76,22 @@ export async function scan1688(profile: SiteProfile, itemId: string | null): Pro
}); });
}); });
source = 'state'; 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 在这里进结果) // ③ DOM 兜底:只读取当前已经挂载的内容,不点击标签、不滚动页面。
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000); const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile); 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 texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile); const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, itemId, texts, images, [], source); 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(', ')}`); if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result; return result;
} }
@@ -5,7 +5,7 @@
* ③ 站内页 JSON API(entrypoint-api.bx) * ③ 站内页 JSON API(entrypoint-api.bx)
* ④ DOM data-widget 选择器兜底 + 详情图补充 * ④ DOM data-widget 选择器兜底 + 详情图补充
*/ */
import { autoScrollToBottom, waitForAny } from '../dom'; import { activateDetailTab, autoScrollToBottom, waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image'; import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { toOriginalUrl, toThumbUrl } from '../url'; import { toOriginalUrl, toThumbUrl } from '../url';
@@ -148,6 +148,7 @@ export async function scanOzon(profile: SiteProfile, itemId: string | null): Pro
// ── 路径④:DOM 采集(兜底 + 详情图补充)── // ── 路径④:DOM 采集(兜底 + 详情图补充)──
// 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流 // 先滚到底触发懒加载(详情图不滚不加载),有界滚动防无限推荐流
await activateDetailTab();
await autoScrollToBottom(); await autoScrollToBottom();
const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000); const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 8_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
@@ -3,15 +3,15 @@
* ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径 * ① MAIN world 桥读页面全局(__ICE_APP_CONTEXT__ 等)★主路径
* (isolated world 读不到 window 变量,v1 直读是无效的) * (isolated world 读不到 window 变量,v1 直读是无效的)
* ② DOM 兜底 + 补充 * ② DOM 兜底 + 补充
* 说明:不再直调 mtop 签名接口(h5api.m.taobao.com / h5api.m.tmall.com), * 说明:图文详情通过页面已有登录态请求 mtop 描述接口;不点击标签、不滚动页面。
* 仅读取页面已加载数据(SSR 全局 + DOM),避免触发平台风控。
*/ */
import { autoScrollToBottom, waitForAny } from '../dom'; import { waitForAny } from '../dom';
import { collectImages, type ImageMaterial } from '../image'; import { collectImages, type ImageMaterial } from '../image';
import { collectTexts, mergeTexts, type TextMaterial } from '../text'; import { collectTexts, mergeTexts, type TextMaterial } from '../text';
import { readWindowKeys } from '../../bridge/read-window'; import { readWindowKeys } from '../../bridge/read-window';
import { buildFromSSR } from '../ssr-builder'; import { buildFromSSR } from '../ssr-builder';
import { taobaoStateFromBridge } from '../taobao-state'; import { taobaoStateFromBridge } from '../taobao-state';
import { collectTaobaoDetailImages } from '../taobao-detail';
import { finalize, mergeImages, type ScanResult } from '../merge'; import { finalize, mergeImages, type ScanResult } from '../merge';
import type { SiteProfile } from '../../profiles/types'; import type { SiteProfile } from '../../profiles/types';
@@ -19,6 +19,7 @@ export async function scanTaobao(profile: SiteProfile, _itemId: string | null):
let primaryTexts: TextMaterial[] = []; let primaryTexts: TextMaterial[] = [];
let primaryImages: ImageMaterial[] = []; let primaryImages: ImageMaterial[] = [];
let source: ScanResult['source'] = 'dom'; let source: ScanResult['source'] = 'dom';
const detailWarnings: string[] = [];
// ① 桥读页面全局 // ① 桥读页面全局
const keys = await readWindowKeys(['__ICE_APP_CONTEXT__', '__general_skupanel_cache_data', '__ICE_DATA_LOADER__']); 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'; source = 'ssr';
} }
// ② DOM 兜底 + 补充 // ② 图文详情 API:使用页面已有登录态签名请求,不点击标签、不滚动页面。
// 慢速分段滚动触发懒加载(详情图不滚不加载),有界滚动防无限推荐流 if (_itemId) {
await autoScrollToBottom(); 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); const anchor = await waitForAny(profile.readySelectors, profile.readyTimeoutMs ?? 10_000);
if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)'); if (!anchor) console.warn('[SuiteCollector] 等待页面就绪超时(继续尝试 DOM 采集)');
const { materials: domTexts, missingRequired } = collectTexts(profile); 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 texts = mergeTexts(primaryTexts, domTexts);
const images = mergeImages(primaryImages, domImages, profile); const images = mergeImages(primaryImages, domImages, profile);
const result = finalize(profile, _itemId, texts, images, [], source); 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(', ')}`); if (missingRequired.length > 0) result.warnings.push(`缺少必需字段: ${missingRequired.join(', ')}`);
return result; return result;
} }
@@ -139,9 +139,9 @@ export function buildFromSSR(data: SSRData, profile: SiteProfile): ScanResult {
if (images.length === 0) { if (images.length === 0) {
warnings.push('未扫描到任何图片/视频'); warnings.push('未扫描到任何图片/视频');
} }
// SSR 数据里没有详情图,需要 DOM 补充 // SSR 数据里没有详情图;由平台接口或当前已挂载 DOM 补充。
if (stats.detail === undefined) { if (stats.detail === undefined) {
warnings.push('详情图需 DOM 补充:请滚动到页面底部后重新采集'); warnings.push('详情图需补充:页面未提供可读取的图文详情数据');
} }
return { 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://detail.1688.com/*',
'https://item.taobao.com/*', 'https://item.taobao.com/*',
'https://detail.tmall.com/*', 'https://detail.tmall.com/*',
'https://h5api.m.taobao.com/*',
'https://h5api.m.tmall.com/*',
'https://*.alicdn.com/*', 'https://*.alicdn.com/*',
// 1688 详情数据 CDN(description.detailUrl) // 1688 详情数据 CDN(description.detailUrl)
'https://itemcdn.tmall.com/*', '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 平移自 image-suite-studio;路径按 docs/v2.1/api.md §7 定为 POST /api/export/images
(前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。 (前端 services/suite.ts exportImages 同款契约,注意与 ISS 的 /export-images 不同)。
ZIP 内部按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传), 采集图片默认按分组名建子文件夹(主图 / SKU图片 / 详情图 / 手动上传)。生成结果可
文件名沿用采集 key(main-001 等)+ SKU 规格名;顶层文件夹用商品标题(清洗后)。 请求扁平导出,所有图片直接放在商品根目录;文件名沿用采集 key(main-001 等)+ SKU 规格名。
""" """
from __future__ import annotations from __future__ import annotations
@@ -51,6 +51,7 @@ class ExportImageItem(BaseModel):
class ExportImagesRequest(BaseModel): class ExportImagesRequest(BaseModel):
title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名") title: str | None = Field(default=None, description="商品标题,用作 ZIP 顶层文件夹名")
images: list[ExportImageItem] = Field(min_length=1) images: list[ExportImageItem] = Field(min_length=1)
flat: bool = Field(default=False, description="是否忽略图片分组,直接写入 ZIP 顶层商品目录")
def _clean(name: str) -> str: def _clean(name: str) -> str:
@@ -159,8 +160,8 @@ async def export_images(req: ExportImagesRequest):
filename = f"{stem}-{n}{ext}" filename = f"{stem}-{n}{ext}"
used.add(filename) used.add(filename)
group = _clean(img.groupName) or "图片" archive_path = f"{root}/{filename}" if req.flat else f"{root}/{_clean(img.groupName) or '图片'}/{filename}"
zf.writestr(f"{root}/{group}/{filename}", data) zf.writestr(archive_path, data)
if archive.tell() > settings.export_zip_max_bytes: if archive.tell() > settings.export_zip_max_bytes:
raise HTTPException( raise HTTPException(
status_code=413, status_code=413,
+45 -14
View File
@@ -9,6 +9,7 @@ watermark + services/prompts),任务持久化到数据库,由独立 Worker
from __future__ import annotations from __future__ import annotations
import io import io
import re
import tempfile import tempfile
import uuid import uuid
import zipfile import zipfile
@@ -49,25 +50,39 @@ from services.suite_service import texts_to_raw, validate_model
router = APIRouter(prefix="/api", tags=["suite"]) 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: def _with_dimensions(raw: dict, dimensions, *, required: bool = False) -> dict:
"""将表单长宽高放入提示词上下文,不依赖采集参数是否已有尺寸字段。""" """将已填写的尺寸方向放入提示词上下文;未填写方向绝不传给绘制提示词。"""
if dimensions is None: if dimensions is None:
if required: if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高") raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw return raw
values = (dimensions.length, dimensions.width, dimensions.height) known_dimensions = {
if not all(value is not None and value > 0 for value in values): key: value for key, value in {
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图需要完整填写商品长、宽、高")
return raw
enriched = dict(raw)
enriched["dimensions"] = {
"length": dimensions.length, "length": dimensions.length,
"width": dimensions.width, "width": dimensions.width,
"height": dimensions.height, "height": dimensions.height,
"unit": dimensions.unit, }.items() if value is not None and value > 0
} }
if not known_dimensions:
if required:
raise HTTPException(status_code=400, detail="生成尺寸标注图至少需要填写一项商品尺寸")
return raw
enriched = dict(raw)
# 表单尺寸是尺寸标注图的唯一事实来源。原始商品参数常含完整 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 return enriched
@@ -315,7 +330,19 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail="product_id 不是合法 UUID") from 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) is_size_chart = bool(req.generate_size_chart)
task = await create_image_job( task = await create_image_job(
product_id=product_id, product_id=product_id,
@@ -326,20 +353,24 @@ async def suite_image_edit(req: ImageEditSingleRequest) -> ImageEditTaskResponse
platform="ozon", platform="ozon",
lang=spec["lang"], lang=spec["lang"],
ratio=spec["ratio"], ratio=spec["ratio"],
style_set=5, # 仅扩图与用户明确填写的 AI 编辑要求都不得继承默认「纯净棚拍」风格。
style_set=0 if req.mode == "outpaint" or bool(req.prompt.strip()) else 5,
request_payload={ request_payload={
"context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart), "context": _with_dimensions({"title": "AI 精修"}, req.dimensions, required=is_size_chart),
"style_prompt": None, "style_prompt": None,
"requirements": req.prompt, "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, "watermark": req.watermark.model_dump() if req.watermark else None,
"append": bool(req.append and product_id), "append": bool(req.append and product_id),
"after_asset_id": req.after_asset_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=[{ item_payloads=[{
"position": 0, "position": 0,
"kind": "size_chart" if is_size_chart else "custom", "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": "", "detail": "",
"prompt_hint": "", "prompt_hint": "",
"variant_name": None, "variant_name": None,
View File
+8 -3
View File
@@ -77,7 +77,7 @@ class WatermarkOptions(BaseModel):
class ProductDimensions(BaseModel): class ProductDimensions(BaseModel):
"""商品长宽高;尺寸图只会使用完整的三项数据。""" """商品长宽高;尺寸图仅标注实际提供的方向。"""
length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)") length: float | None = Field(default=None, gt=0, description="长度(按 unit 单位)")
width: 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): 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 组") 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="用户要求(可选,作为额外生图指令)") prompt: str = Field(default="", description="用户要求(可选,作为额外生图指令)")
mode: Literal["edit", "outpaint"] = Field(
default="edit",
description="edit 为 AI 编辑;outpaint 只扩展到目标画布,不允许模型改动原图区域",
)
model: str = Field(default="nano-banana-2") model: str = Field(default="nano-banana-2")
append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)") append: bool = Field(default=True, description="结果是否追加为商品素材(generated 组)")
after_asset_id: str | None = Field( after_asset_id: str | None = Field(
+80 -2
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import asyncio import asyncio
import base64 import base64
import io
import logging import logging
import mimetypes import mimetypes
import re import re
@@ -17,6 +18,7 @@ from dataclasses import dataclass
from typing import Awaitable, Callable from typing import Awaitable, Callable
import httpx import httpx
from PIL import Image, ImageOps
from config import get_settings from config import get_settings
from services.prompts import build_context, build_prompt, type_name 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 合法) # gpt-image 自定义尺寸约束:16 的倍数、长短边比 ≤ 3:1(1536x2048 合法)
return "1536x2048" if ratio == "3:4" else "2048x2048" return "1536x2048" if ratio == "3:4" else "2048x2048"
if provider == "pcore": if provider == "pcore":
# pcore 的 size 是枚举(默认 1024x1024),不接受自定义尺寸;用 gpt-image 枚举:竖版 1024x1536(近似 3:4) # 竖图必须与 Ozon 的 3:4 规格一致。1024x1536 实际是 2:3,会使成图明显偏瘦高。
return "1024x1536" if ratio == "3:4" else "1024x1024" return "1152x1536" if ratio == "3:4" else "1024x1024"
# tongyi:万象与千问的 size 语法相同(* 分隔),档位不同 # tongyi:万象与千问的 size 语法相同(* 分隔),档位不同
# wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限 # wan2.6 系列总像素限制在 [1280², 1440²],wan2.7 的 1536*2048/2048*2048 会超限
if model.startswith("wan2.6"): if model.startswith("wan2.6"):
@@ -141,6 +143,75 @@ async def _resolve_ref(url: str) -> str:
return _bytes_to_data_uri(data, mime) 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(火山方舟)──────────────────────────────────── # ── Provider:豆包 Seedream(火山方舟)────────────────────────────────────
async def generate_doubao(prompt: str, ref_images: list[str], size: str = "2048x2048", model: str | None = None) -> bytes: 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, model: str,
provider_task_id: str | None = None, provider_task_id: str | None = None,
provider_task_kind: str | None = None, provider_task_kind: str | None = None,
edit_mask: str | None = None,
) -> GenerationAdvance: ) -> GenerationAdvance:
"""推进一次图片生成,不在本函数内无限轮询。 """推进一次图片生成,不在本函数内无限轮询。
@@ -569,6 +641,8 @@ async def advance_generation(
能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。 能继续外部任务,且在等待期间可以领取其他任务。豆包和同步千问仍在一次调用内完成。
""" """
settings = get_settings() settings = get_settings()
if edit_mask and provider != "pcore":
raise RuntimeError("带蒙版的仅扩图当前仅支持 Pcore gpt-image-2")
if provider == "doubao": if provider == "doubao":
return GenerationAdvance(data=await generate_doubao(prompt, ref_images, size=size, model=model)) 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} body: dict = {"model": model, "prompt": prompt, "n": 1, "size": size, "async": True}
if ref_images: if ref_images:
body["images"] = [{"image_url": await _resolve_ref(item)} for item in 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( response = await client.post(
f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body, f"{base}/v1/images/{kind}", headers={**headers, "Content-Type": "application/json"}, json=body,
) )
+41 -3
View File
@@ -10,7 +10,7 @@ from config import get_settings
from db import get_session_factory from db import get_session_factory
from models import ProductAsset from models import ProductAsset
from services.image_assets import get_image_asset_service 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 ( from services.jobs import (
ClaimedItem, ClaimedItem,
claim_next_item, claim_next_item,
@@ -22,6 +22,7 @@ from services.jobs import (
recover_expired_leases, recover_expired_leases,
) )
from services.prompts import build_context, build_prompt from services.prompts import build_context, build_prompt
from services.prompts.common import requirements_block
from services.storage import get_storage from services.storage import get_storage
from services.suite_service import append_generated_asset from services.suite_service import append_generated_asset
from services.tasks import TaskImage 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)) 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: async def process_item(claim: ClaimedItem) -> None:
"""推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。""" """推进一次子任务。外部模型未完成时只写 provider id 并退出,让出 Worker。"""
if claim.job_kind == "watermark_batch": if claim.job_kind == "watermark_batch":
@@ -174,18 +191,34 @@ async def process_item(claim: ClaimedItem) -> None:
"pcore": "gpt-image-2", "pcore": "gpt-image-2",
}.get(provider, settings.ark_image_model) }.get(provider, settings.ark_image_model)
try: try:
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="") context = build_context(payload.get("context") or {}, fallback_name="")
prompt_extra = job_item.get("prompt_extra") or job_item prompt_extra = job_item.get("prompt_extra") or job_item
prompt = build_prompt( prompt = build_prompt(
provider, model, job_item.get("kind", "custom"), context, claim.style_set, claim.lang, 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"), extra=prompt_extra, style_prompt=payload.get("style_prompt"), requirements=payload.get("requirements"),
requirements_exclusive=bool(payload.get("requirements_exclusive")),
) )
refs = _refs_for_job(list(payload.get("ref_images") or []), job_item)
is_wan = provider == "tongyi" and model.lower().startswith("wan") is_wan = provider == "tongyi" and model.lower().startswith("wan")
result = await advance_generation( result = await advance_generation(
provider, prompt, refs, 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, provider_task_id=claim.provider_task_id, provider_task_kind=claim.provider_task_kind,
edit_mask=edit_mask,
) )
if result.data is None: if result.data is None:
if not result.provider_task_id or not result.provider_task_kind: 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) await mark_item_cancelled(claim.item_id)
return 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 {} watermark = payload.get("watermark") or {}
position = int(job_item.get("position", 0)) position = int(job_item.get("position", 0))
if watermark.get("enabled"): if watermark.get("enabled"):
+4 -4
View File
@@ -56,11 +56,11 @@ SYSTEM_PROMPT = """你是一名资深电商视觉策划。根据商品信息规
5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。 5. variant_name 只能从「SKU规格」列表原样照抄;没有绑定就输出 null。
## 规划规则 ## 规划规则
1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的长宽高/尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。 1. 套图必须均衡覆盖:白底主图、场景图、细节图;参数含可靠的任一方向尺寸时还必须有尺寸标注图。按这个顺序先预留张数,再安排其他类型。
2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。 2. 白底主图(kind=white_bg)通常只出 1 张;有多个视觉差异明显的 SKU 时最多选 2 个代表款各出 1 张,variant_name 填对应规格名。**禁止因为 SKU 多而为每个 SKU 都规划主图**。仅当「生图要求」明确要求每个/全部 SKU 分别出图时才可例外。
3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。 3. 场景图(kind=lifestyle):至少 1 张,按核心使用场景出 1-2 张,每张聚焦一个场景。
4. 细节图(kind=material / key_features / selling_pt / ecommerce_detail):至少 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 张,优先级低于上述四类。 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。 7. kind 枚举:white_bg / key_features / selling_pt / material / lifestyle / multi_scene / ecommerce_detail / size_chart / sku_collection / custom。
8. title 用中文短语(如「主图·粉色」「浴室壁挂」);detail 中文说明这张图展示什么;prompt_hint 用英文描述构图要点。 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: def _has_dimensions(product_info: dict) -> bool:
"""仅在已采集到尺寸信息时规划尺寸图,避免模型编造数值。""" """有任一可靠尺寸即可规划尺寸图;未给出的方向不允许模型编造。"""
dimensions = product_info.get("dimensions") or {} 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 return True
for param in product_info.get("params") or []: for param in product_info.get("params") or []:
if not isinstance(param, dict): 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, 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, 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。""" """按模型家族构造指定图类型的完整生图 prompt。参数含义见各家族 build_prompt。"""
module = _MODULE_BY_FAMILY[prompt_family(provider, model)] module = _MODULE_BY_FAMILY[prompt_family(provider, model)]
return module.build_prompt( return module.build_prompt(
type_id, ctx, style_set, lang, type_id, ctx, style_set, lang,
extra=extra, style_prompt=style_prompt, requirements=requirements, 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: def _prompt_size_chart(ctx: dict, style: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang) dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return ( return (
f"Product size chart infographic for \"{ctx['title']}\": product shown in clean front and side views " 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"measurement values rendered next to each line"
+ (f" (known specs: {dims})" if dims else "") + (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}" 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, 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(主体参考语义)。 """构造指定图类型的完整生图 prompt(主体参考语义)。
extra: 方案项信息 {title, detail, prompt_hint}——custom 类型必需, 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 最前面, 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) style = resolve_style(style_set, style_prompt)
extra = extra or {} extra = extra or {}
if type_id == "custom": if type_id == "custom":
+23 -5
View File
@@ -134,11 +134,28 @@ def build_context(raw: dict, fallback_name: str = "", fallback_desc: str = "") -
break break
dimensions = raw.get("dimensions") or {} dimensions = raw.get("dimensions") or {}
dimension_values = (dimensions.get("length"), dimensions.get("width"), dimensions.get("height")) if isinstance(dimensions, dict) else () known_dimensions: list[tuple[str, object]] = []
dimensions_line = "" if isinstance(dimensions, dict):
if len(dimension_values) == 3 and all(value is not None for value in dimension_values): known_dimensions = [
unit = dimensions.get("unit") or "mm" (label, dimensions[key]) for key, label in (("length", "length"), ("width", "width"), ("height", "height"))
dimensions_line = "L × W × H: " + " × ".join(str(value) for value in dimension_values) + f" {unit}" 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( params_line = "; ".join(
f"{p.get('key')}: {p.get('value')}" for p in (raw.get("params") or [])[:8] 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, dimensions_line,
))), ))),
"price": raw.get("price") or "", "price": raw.get("price") or "",
"dimension_annotation_instruction": dimension_annotation_instruction,
} }
def selling_point_lines(ctx: dict, lang: str, max_n: int = 3) -> str: 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: def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang) dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return ( return (
"Create a size chart: the unchanged product in clean front and side views on a light background, " "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" "each line"
+ (f" (known specs: {dims})" if dims else "") + (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: 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, 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:要求块 → 指令 → 主体锁 → 锚定 → 风格 → 文案 → 画质 → 提醒。""" """构造指定图类型的 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) style = resolve_style(style_set, style_prompt)
extra = extra or {} extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip() 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: def _task_size_chart(ctx: dict, lang: str) -> str:
dims = params_line_for_output(ctx, lang) dims = params_line_for_output(ctx, lang)
annotation = ctx.get("dimension_annotation_instruction") or "Do not invent measurement directions or values."
return ( return (
"TASK: Measurement chart. Show the unchanged product in clean front and side views on a light " "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 " "background; overlay thin technical annotation lines (arrows) only for the supplied dimensions, "
"with measurement values rendered beside each line" "with each measurement value rendered beside its line"
+ (f" (known specs: {dims})" if dims else "") + (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: 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, 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:要求块 → 编辑指令 → 保真锁 → 锚定 → 风格 → 文案 → 画质 → 终检。""" """构造指定图类型的 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) style = resolve_style(style_set, style_prompt)
extra = extra or {} extra = extra or {}
hint = (extra.get("prompt_hint") or "").strip() hint = (extra.get("prompt_hint") or "").strip()
+10
View File
@@ -58,11 +58,16 @@ async def _run_process(
should_cancel: Callable[[], Awaitable[bool]], should_cancel: Callable[[], Awaitable[bool]],
timeout_seconds: int, timeout_seconds: int,
) -> None: ) -> None:
try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*args, *args,
stdout=asyncio.subprocess.DEVNULL, stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
) )
except FileNotFoundError as exc:
raise VideoRenderError(
f"未找到视频合成工具「{args[0]}」。请安装 FFmpeg,并配置 VIDEO_FFMPEG_PATH 为其可执行文件的绝对路径。"
) from exc
try: try:
waited = 0.0 waited = 0.0
while process.returncode is None: while process.returncode is None:
@@ -91,6 +96,7 @@ async def _run_process(
async def _probe(path: Path) -> dict: async def _probe(path: Path) -> dict:
settings = get_settings() settings = get_settings()
try:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
settings.video_ffprobe_path, settings.video_ffprobe_path,
"-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt", "-v", "error", "-show_entries", "format=duration,size:stream=codec_name,width,height,pix_fmt",
@@ -98,6 +104,10 @@ async def _probe(path: Path) -> dict:
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=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() stdout, stderr = await process.communicate()
if process.returncode != 0: if process.returncode != 0:
raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}") raise VideoRenderError(f"无法校验视频输出:{stderr.decode('utf-8', errors='replace')[-400:]}")
+21 -1
View File
@@ -1,8 +1,10 @@
"""单图导出响应头回归测试。""" """单图导出响应头回归测试。"""
from __future__ import annotations from __future__ import annotations
import io
import sys import sys
import unittest import unittest
import zipfile
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, patch 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: if str(SERVER_DIR) not in sys.path:
sys.path.insert(0, str(SERVER_DIR)) 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): class ExportImageTests(unittest.IsolatedAsyncioTestCase):
@@ -27,6 +29,24 @@ class ExportImageTests(unittest.IsolatedAsyncioTestCase):
self.assertIn('filename="_____-__.png"', disposition) 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) 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__": if __name__ == "__main__":
unittest.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 { useEffect, useState } from 'react';
import { Col, InputNumber, message, Radio, Row, Space, Tag, Typography } from 'antd'; 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 { getFxRate } from '@/services/fx';
import { ProductDetail } from '@/services/product'; import { ProductDetail } from '@/services/product';
import { apiErrorMessage } from '@/services/api'; 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) { export default function PriceInfoPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload; const pricing = (product.pricing ?? {}) as PricingPayload;
const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30); const [purchasePrice, setPurchasePrice] = useState(pricing.purchasePrice ?? 30);
const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100); const [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low'); 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 [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
// 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次 // 币种固定人民币:历史数据(默认 RUB)打开编辑页时纠正一次
@@ -67,7 +69,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
const tdPrice = pricing.tdPrice ?? 3; const tdPrice = pricing.tdPrice ?? 3;
/** 用当前参数(可被 patch 覆盖)实时计算,不落库 */ /** 用当前参数(可被 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({ calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice, purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate, profitRate: patch.profitRate ?? profitRate,
@@ -75,17 +77,17 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product), weightG: weightGrams(product),
dims: dimsCm(product), dims: dimsCm(product),
tdPrice, tdPrice,
lineMultiplier: patch.multiplier ?? multiplier, lineDiscountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate, 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 = { const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice, purchasePrice: patch.purchasePrice ?? purchasePrice,
profitRate: patch.profitRate ?? profitRate, profitRate: patch.profitRate ?? profitRate,
level: patch.level ?? level, level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier, discountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate, fxRate: patch.fxRate ?? fxRate,
}; };
const r = compute(patch); const r = compute(patch);
@@ -98,7 +100,7 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
weightG: weightGrams(product), weightG: weightGrams(product),
dims: dimsCm(product), dims: dimsCm(product),
tdPrice, tdPrice,
lineMultiplier: p.multiplier, lineDiscountRate: p.discountRate,
fxRate: p.fxRate, fxRate: p.fxRate,
logisticsFee: r.logisticsFee, logisticsFee: r.logisticsFee,
fullCommission: r.fullCommission, fullCommission: r.fullCommission,
@@ -189,15 +191,16 @@ export default function PriceInfoPanel({ product, onSave }: Props) {
/> />
</Col> </Col>
<Col span={5}> <Col span={5}>
<FieldLabel>划线价倍数 %</FieldLabel> <FieldLabel>划线折扣率 %</FieldLabel>
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
max={500} max={99.99}
value={multiplier} precision={2}
value={discountRate}
onChange={(v) => { onChange={(v) => {
setMultiplier(v ?? 0); setDiscountRate(v ?? 0);
recalc({ multiplier: v ?? 0 }); recalc({ discountRate: v ?? 0 });
}} }}
/> />
</Col> </Col>
+114 -21
View File
@@ -1,5 +1,5 @@
import { Fragment, useEffect, useRef, useState } from 'react'; 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 { CloseOutlined } from '@ant-design/icons';
import { apiErrorMessage } from '@/services/api'; import { apiErrorMessage } from '@/services/api';
import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite'; import { DEFAULT_IMAGE_MODEL, IMAGE_MODEL_OPTIONS, cancelSuite, getSuite, imageEditSingle, ProductDimensions, WatermarkPayload } from '@/services/suite';
@@ -11,6 +11,8 @@ interface Props {
productId: string; productId: string;
/** 待生成的源图(采集图或生成图) */ /** 待生成的源图(采集图或生成图) */
source: { url: string; name: string; afterAssetId?: string } | null; source: { url: string; name: string; afterAssetId?: string } | null;
/** 可作为参考底图的商品图片;当前点开的图片会默认选中。 */
referenceOptions: Array<{ url: string; name: string }>;
onClose: () => void; onClose: () => void;
/** 生成成功回调(服务端 append 后刷新素材列表) */ /** 生成成功回调(服务端 append 后刷新素材列表) */
onGenerated?: (url: string) => void; onGenerated?: (url: string) => void;
@@ -24,15 +26,17 @@ interface Props {
* 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。 * 单张 AI 图生图弹窗:输入要求 + 选模型 → 生成。
* 对应 POST /api/suite/image-edit(docs/v2.1/api.md §6,服务端 Phase B 实现)。 * 对应 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 [prompt, setPrompt] = useState('');
const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL); const [model, setModel] = useState<string>(DEFAULT_IMAGE_MODEL);
const [mode, setMode] = useState<'edit' | 'outpaint'>('edit');
const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' }); const [dimensions, setDimensions] = useState<ProductDimensions>({ unit: 'mm' });
const [generateSizeChart, setGenerateSizeChart] = useState(false); const [generateSizeChart, setGenerateSizeChart] = useState(false);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
const [resultUrl, setResultUrl] = useState(''); const [resultUrl, setResultUrl] = useState('');
const [taskId, setTaskId] = useState(''); const [taskId, setTaskId] = useState('');
const [taskError, setTaskError] = useState(''); const [taskError, setTaskError] = useState('');
const [selectedReferenceUrls, setSelectedReferenceUrls] = useState<Set<string>>(() => new Set());
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pollAbortRef = useRef<AbortController | null>(null); const pollAbortRef = useRef<AbortController | null>(null);
const pollRunRef = useRef(0); const pollRunRef = useRef(0);
@@ -50,14 +54,16 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
useEffect(() => { useEffect(() => {
if (open && !activeTaskRef.current) { if (open && !activeTaskRef.current) {
setPrompt(''); setPrompt('');
setMode('edit');
setDimensions({ unit: 'mm' }); setDimensions({ unit: 'mm' });
setGenerateSizeChart(false); setGenerateSizeChart(false);
setResultUrl(''); setResultUrl('');
setGenerating(false); setGenerating(false);
setTaskId(''); setTaskId('');
setTaskError(''); setTaskError('');
setSelectedReferenceUrls(source?.url ? new Set([source.url]) : new Set());
} }
}, [open]); }, [open, source?.url]);
useEffect(() => () => stopPolling(), []); useEffect(() => () => stopPolling(), []);
@@ -130,10 +136,18 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
const onGenerate = async () => { const onGenerate = async () => {
if (!source) return; if (!source) return;
const hasCompleteDimensions = [dimensions.length, dimensions.width, dimensions.height] if (selectedReferenceUrls.size === 0) {
.every((value) => typeof value === 'number' && value > 0); message.warning('请至少选择一张参考底图');
if (generateSizeChart && !hasCompleteDimensions) { return;
message.warning('生成尺寸标注图前,请完整填写商品长、宽、高'); }
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; return;
} }
setGenerating(true); setGenerating(true);
@@ -142,14 +156,15 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
try { try {
const r = await imageEditSingle({ const r = await imageEditSingle({
product_id: productId, product_id: productId,
image_url: source.url, image_urls: Array.from(selectedReferenceUrls),
prompt: prompt.trim(), prompt: prompt.trim(),
mode,
model, model,
append: true, append: true,
after_asset_id: source.afterAssetId, after_asset_id: source.afterAssetId,
watermark: watermark?.enabled ? watermark : undefined, watermark: watermark?.enabled ? watermark : undefined,
generate_size_chart: generateSizeChart, generate_size_chart: generateSizeChart,
dimensions: hasCompleteDimensions ? dimensions : null, dimensions: hasAnyDimensions ? dimensions : null,
}); });
setTaskId(r.suite_id); setTaskId(r.suite_id);
message.info('已提交后台生成,可继续操作;进度将在「生成结果」区域显示'); 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 ( return (
<> <>
{open && ( {open && (
@@ -209,18 +254,64 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}> <div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Text strong style={{ fontSize: 15 }}>AI 生图(图生图)</Text> <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} /> <Button type="text" shape="circle" icon={<CloseOutlined />} title="关闭" aria-label="关闭" onClick={onClose} />
</div> </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={{ display: 'flex', gap: 16 }}>
<div style={{ width: 260, flexShrink: 0 }}> <div style={{ width: 260, flexShrink: 0 }}>
<Image <Image
src={source?.url} src={source?.url}
alt="原图" alt="当前图片"
style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }} style={{ borderRadius: 8, objectFit: 'contain', maxHeight: 300 }}
/> />
</div> </div>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{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 }}> <div style={{ marginBottom: 12 }}>
<Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text> <Text type="secondary" style={{ display: 'block', fontSize: 12, marginBottom: 4 }}>商品尺寸(长 × 宽 × 高)</Text>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
@@ -248,13 +339,21 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
/> />
</div> </div>
</div> </div>
)}
<Input.TextArea <Input.TextArea
rows={5} rows={4}
value={prompt} value={prompt}
onChange={(e) => setPrompt(e.target.value)} onChange={(e) => setPrompt(e.target.value)}
placeholder={'生图要求(可选),例如:把背景换成纯白色摄影棚,保留商品细节与比例;留空则按原图生成'} placeholder={mode === 'outpaint'
? '扩展区域要求(可选),例如:将新增边缘扩展为纯黑背景;原图区域不会被改动'
: '编辑要求(可选)。填写后会只执行此要求,不会附加营销图、俄文文字或默认棚拍风格'}
disabled={generating} 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={{ marginTop: 12, display: 'flex', justifyContent: 'flex-end' }}>
<div style={{ display: 'flex', width: '100%', gap: 8 }}> <div style={{ display: 'flex', width: '100%', gap: 8 }}>
<Select <Select
@@ -263,7 +362,9 @@ export default function AiImageGenModal({ open, productId, source, onClose, onGe
value={model} value={model}
onChange={setModel} onChange={setModel}
disabled={generating} 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) => ( optionRender={(option) => (
<div> <div>
<div>{option.label}</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 }}> <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 type="primary" loading={generating} onClick={onGenerate}>
提交任务 提交任务
</Button> </Button>
+2 -2
View File
@@ -29,7 +29,7 @@ interface RegisterRecord {
sellingPriceReserved: string; // 划线价 ¥(v1 预留价位) sellingPriceReserved: string; // 划线价 ¥(v1 预留价位)
sellingPriceRub: string; sellingPriceRub: string;
sellingPriceRubReserved: string; // 划线价 ₽ sellingPriceRubReserved: string; // 划线价 ₽
lineMultiplier: number; // 划线价倍数 %(v1 discountReserve 位) lineDiscountRate: number; // 划线折扣率 %
exchangeRate: string; exchangeRate: string;
logisticsFee: string; logisticsFee: string;
receivedPrice: string; receivedPrice: string;
@@ -95,7 +95,7 @@ function toRecord(product: ProductDetail): RegisterRecord | null {
sellingPriceReserved: f(pr.linePriceCny), sellingPriceReserved: f(pr.linePriceCny),
sellingPriceRub: f(pr.sellingPriceRub), sellingPriceRub: f(pr.sellingPriceRub),
sellingPriceRubReserved: f(pr.linePriceRub), sellingPriceRubReserved: f(pr.linePriceRub),
lineMultiplier: pr.lineMultiplier ?? 100, lineDiscountRate: pr.lineDiscountRate ?? 50,
exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '', exchangeRate: pr.fxRate != null ? Number(pr.fxRate).toFixed(4) : '',
logisticsFee: f(pr.logisticsFee), logisticsFee: f(pr.logisticsFee),
receivedPrice: f(pr.receivedPrice), receivedPrice: f(pr.receivedPrice),
+56 -41
View File
@@ -7,6 +7,7 @@ import { apiErrorMessage } from '@/services/api';
import { copyText } from '@/utils/file'; import { copyText } from '@/utils/file';
import { import {
calculatePricing, calculatePricing,
legacyLineMultiplierToDiscountRate,
logisticsFeeRule, logisticsFeeRule,
validateDimensions, validateDimensions,
validateLogisticsLevelCny, validateLogisticsLevelCny,
@@ -30,7 +31,7 @@ interface Props {
type PricingPayload = Record<string, any>; type PricingPayload = Record<string, any>;
type Dims = { l: number | null; w: number | null; h: number | null }; type Dims = { l: number | null; w: number | null; h: number | null };
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
type PricingState = Partial<{ purchasePrice: number; profitRate: number; tdPrice: number; level: LogisticsLevel; multiplier: number; fxRate: number; 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 { function dimsCm(product: ProductDetail): Dims {
const scale = product.dimension_unit === 'cm' ? 1 : 0.1; const scale = product.dimension_unit === 'cm' ? 1 : 0.1;
@@ -60,7 +61,7 @@ function SalePriceGrid({
cnyLine, cnyLine,
rubSelling, rubSelling,
rubLine, rubLine,
multiplier, discountRate,
fxRate, fxRate,
cnyGap, cnyGap,
rubGap, rubGap,
@@ -70,7 +71,7 @@ function SalePriceGrid({
cnyLine: string; cnyLine: string;
rubSelling: string; rubSelling: string;
rubLine: string; rubLine: string;
multiplier: number; discountRate: number;
fxRate: number; fxRate: number;
cnyGap: number | null; cnyGap: number | null;
rubGap: number | null; rubGap: number | null;
@@ -88,7 +89,7 @@ function SalePriceGrid({
lineHeight: '30px', lineHeight: '30px',
}; };
const noteStyle: CSSProperties = { fontSize: 11, color: 'rgba(0,0,0,0.35)' }; 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 () => { const copyCnySelling = async () => {
if (!cnySellingNumber) return; if (!cnySellingNumber) return;
@@ -133,7 +134,7 @@ function SalePriceGrid({
<div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div> <div style={{ fontSize: 13, fontWeight: 500 }}>人民币-划线价</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div> <div style={{ ...priceStyle, textDecoration: 'line-through' }}>{cnyLine}</div>
<div style={noteStyle}> <div style={noteStyle}>
销售价 × {multiple} 划线折扣 {discountText}
{cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''} {cnyGap != null ? ` · 差额 ¥ ${cnyGap.toFixed(2)}` : ''}
</div> </div>
</div> </div>
@@ -141,7 +142,7 @@ function SalePriceGrid({
<div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div> <div style={{ fontSize: 13, fontWeight: 500 }}>卢布-划线价</div>
<div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div> <div style={{ ...priceStyle, textDecoration: 'line-through' }}>{rubLine}</div>
<div style={noteStyle}> <div style={noteStyle}>
销售价 × {multiple} 划线折扣 {discountText}
{rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''} {rubGap != null ? ` · 差额 ₽ ${rubGap.toFixed(0)}` : ''}
</div> </div>
</div> </div>
@@ -153,18 +154,20 @@ function SalePriceGrid({
/** /**
* 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。 * 02 价格试算:公式与告警完整移植 v1 web/js/app.js(trial-page.md §4)。
* 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。 * 布局:左侧操作项(进货价→物流等级),右侧展示项(物流费→销售价)。
* 划线价为「倍数」口径:划线价 = 销售价 × (1 + 倍数%),0-500% 线性可算(v2.1 调整)。 * 划线价按折扣率计算:划线价 = 销售价 ÷ (1 - 划线折扣率)。
* 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。 * 颜色对齐 v1:完全成本/平台总抽成红色,人民币/卢布销售价绿色。
* 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate); * 参数变化即重算并落库(pricing JSON + price/old_price/fx_rate);
* 尺寸不合规时不落计价结果。 * 尺寸不合规时不落计价结果。
*/ */
export default function TrialPricingPanel({ product, onSave }: Props) { export default function TrialPricingPanel({ product, onSave }: Props) {
const pricing = (product.pricing ?? {}) as PricingPayload; 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 [profitRate, setProfitRate] = useState(pricing.profitRate ?? 100);
const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3); const [tdPrice, setTdPrice] = useState(pricing.tdPrice ?? 3);
const [level, setLevel] = useState<LogisticsLevel>((pricing.logisticsLevel as LogisticsLevel) ?? 'low'); 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 [fxRate, setFxRate] = useState(product.fx_rate ?? pricing.fxRate ?? 0);
const [fxSource, setFxSource] = useState(''); const [fxSource, setFxSource] = useState('');
// 重量/尺寸:计价输入,落 product 包装字段(本面板编辑) // 重量/尺寸:计价输入,落 product 包装字段(本面板编辑)
@@ -206,39 +209,43 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const compute = (patch: PricingState = {}): PricingResult => const compute = (patch: PricingState = {}): PricingResult =>
calculatePricing({ calculatePricing({
purchasePrice: patch.purchasePrice ?? purchasePrice, purchasePrice: patch.purchasePrice ?? purchasePrice ?? 0,
profitRate: patch.profitRate ?? profitRate, profitRate: patch.profitRate ?? profitRate,
logisticsLevel: patch.level ?? level, logisticsLevel: patch.level ?? level,
weightG: wG(patch.weightG ?? weight), weightG: wG(patch.weightG ?? weight),
dims: dimsNum(patch.dims ?? dims), dims: dimsNum(patch.dims ?? dims),
tdPrice: patch.tdPrice ?? tdPrice, tdPrice: patch.tdPrice ?? tdPrice,
lineMultiplier: patch.multiplier ?? multiplier, lineDiscountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate, fxRate: patch.fxRate ?? fxRate,
}); });
/** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */ /** 重算并落库(patch 为本次变化的参数,避免闭包旧值;extraSave 随本次一并落库的商品字段) */
const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => { const recalc = (patch: PricingState = {}, extraSave?: Partial<ProductDetail>) => {
const p = { const p = {
purchasePrice: patch.purchasePrice ?? purchasePrice, purchasePrice: patch.purchasePrice !== undefined ? patch.purchasePrice : purchasePrice,
profitRate: patch.profitRate ?? profitRate, profitRate: patch.profitRate ?? profitRate,
tdPrice: patch.tdPrice ?? tdPrice, tdPrice: patch.tdPrice ?? tdPrice,
level: patch.level ?? level, level: patch.level ?? level,
multiplier: patch.multiplier ?? multiplier, discountRate: patch.discountRate ?? discountRate,
fxRate: patch.fxRate ?? fxRate, fxRate: patch.fxRate ?? fxRate,
weightG: wG(patch.weightG ?? weight), weightG: patch.weightG !== undefined ? patch.weightG : weight,
dimsNow: dimsNum(patch.dims ?? dims), dimsNow: patch.dims ?? dims,
}; };
const dimError = validateDimensions(p.weightG, p.dimsNow, p.level); const complete = p.purchasePrice != null && p.weightG != null
const r = dimError ? null : calculatePricing({ && [p.dimsNow.l, p.dimsNow.w, p.dimsNow.h].every((value) => value != null)
purchasePrice: p.purchasePrice, && 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, profitRate: p.profitRate,
logisticsLevel: p.level, logisticsLevel: p.level,
weightG: p.weightG, weightG: p.weightG!,
dims: p.dimsNow, dims: dimsForCalculation,
tdPrice: p.tdPrice, tdPrice: p.tdPrice,
lineMultiplier: p.multiplier, lineDiscountRate: p.discountRate,
fxRate: p.fxRate, fxRate: p.fxRate,
}); }) : null;
onSave({ onSave({
...extraSave, ...extraSave,
pricing: { pricing: {
@@ -247,7 +254,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
profitRate: p.profitRate, profitRate: p.profitRate,
tdPrice: p.tdPrice, tdPrice: p.tdPrice,
logisticsLevel: p.level, logisticsLevel: p.level,
lineMultiplier: p.multiplier, lineDiscountRate: p.discountRate,
fxRate: p.fxRate, fxRate: p.fxRate,
weightG: p.weightG, weightG: p.weightG,
dims: p.dimsNow, dims: p.dimsNow,
@@ -276,7 +283,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/** 重量变化:改包装字段 + 立即重算(单次 PATCH) */ /** 重量变化:改包装字段 + 立即重算(单次 PATCH) */
const onWeightChange = (v: number | null) => { const onWeightChange = (v: number | null) => {
setWeight(v); setWeight(v);
recalc({ weightG: v ?? 0 }, { weight: v, weight_unit: 'g' }); recalc({ weightG: v }, { weight: v, weight_unit: 'g' });
}; };
/** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */ /** 尺寸变化(key: l/w/h):改对应包装字段 + 立即重算(单次 PATCH) */
@@ -292,8 +299,11 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
const curWeightG = wG(weight); const curWeightG = wG(weight);
const curDims = dimsNum(dims); const curDims = dimsNum(dims);
const dimError = fxRate ? validateDimensions(curWeightG, curDims, level) : ''; const hasCompleteInputs = purchasePrice != null && weight != null
const preview = fxRate && !dimError ? compute() : 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 levelHint = preview ? validateLogisticsLevelCny(preview.sellingPriceCny, level) : '';
const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : ''; const rangeHint = preview ? validatePriceRange(preview.sellingPriceCny) : '';
const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0; const discountGap = preview ? preview.linePriceCny - preview.sellingPriceCny : 0;
@@ -310,6 +320,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
placeholder="请填写"
value={weight} value={weight}
onChange={(v) => onWeightChange(v)} onChange={(v) => onWeightChange(v)}
/> />
@@ -319,6 +330,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
placeholder="请填写"
value={dims.l} value={dims.l}
onChange={(v) => onDimChange('l', v)} onChange={(v) => onDimChange('l', v)}
/> />
@@ -328,6 +340,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
placeholder="请填写"
value={dims.w} value={dims.w}
onChange={(v) => onDimChange('w', v)} onChange={(v) => onDimChange('w', v)}
/> />
@@ -337,6 +350,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
placeholder="请填写"
value={dims.h} value={dims.h}
onChange={(v) => onDimChange('h', v)} onChange={(v) => onDimChange('h', v)}
/> />
@@ -344,15 +358,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
</Row> </Row>
<Row gutter={16} style={fieldRowStyle}> <Row gutter={16} style={fieldRowStyle}>
<Col span={12}> <Col span={12}>
<FieldLabel>进货价 ¥</FieldLabel> <FieldLabel>净货价 ¥</FieldLabel>
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
placeholder="请填写"
value={purchasePrice} value={purchasePrice}
onChange={(v) => { onChange={(v) => {
const n = v ?? 0; setPurchasePrice(v);
setPurchasePrice(n); recalc({ purchasePrice: v });
recalc({ purchasePrice: n });
}} }}
/> />
</Col> </Col>
@@ -385,17 +399,18 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
/> />
</Col> </Col>
<Col span={12}> <Col span={12}>
<FieldLabel>划线价倍数 %</FieldLabel> <FieldLabel>划线折扣率 %</FieldLabel>
<InputNumber <InputNumber
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
max={500} max={99.99}
step={5} precision={2}
value={multiplier} step={1}
value={discountRate}
onChange={(v) => { onChange={(v) => {
const n = v ?? 0; const n = v ?? 0;
setMultiplier(n); setDiscountRate(n);
recalc({ multiplier: n }); recalc({ discountRate: n });
}} }}
/> />
</Col> </Col>
@@ -450,7 +465,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
{dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />} {dimError && <Alert type="error" showIcon message="尺寸不符合物流要求" description={dimError} />}
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}> <Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 12 }}>
划线价 = 销售价 × (1 + 倍数%);参数与重量/尺寸变化自动计价并入库;尺寸不合规时不落计价结果。 划线价 = 销售价 ÷ (1 − 划线折扣率);折扣率需 ≥ 0 且 &lt; 100%。填写净货价、重量和长宽高后自动计价。
</Text> </Text>
{(levelHint || rangeHint) && ( {(levelHint || rangeHint) && (
@@ -496,15 +511,15 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
<Metric <Metric
label="完全成本" label="完全成本"
value={preview ? `¥ ${num(preview.totalCost)}` : '--'} value={preview ? `¥ ${num(preview.totalCost)}` : '--'}
rule="(进货价 + 物流费 + 平台总抽成)" rule="(净货价 + 物流费 + 平台总抽成)"
color={COLOR_RED} color={COLOR_RED}
/> />
</Col> </Col>
<Col span={8}> <Col span={8}>
<Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(进货价 × 净利率)" /> <Metric label="净利润" value={preview ? `¥ ${num(preview.profitPrice)}` : '--'} rule="(净货价 × 净利率)" />
</Col> </Col>
<Col span={8}> <Col span={8}>
<Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(进货价 × (1 + 净利率))" /> <Metric label="实收价" value={preview ? `¥ ${num(preview.receivedPrice)}` : '--'} rule="(净货价 × (1 + 净利率))" />
</Col> </Col>
</Row> </Row>
@@ -515,7 +530,7 @@ export default function TrialPricingPanel({ product, onSave }: Props) {
cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'} cnyLine={preview ? `¥ ${num(preview.linePriceCny)}` : '--'}
rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'} rubSelling={preview ? `₽ ${num(preview.sellingPriceRub, 0)}` : '--'}
rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'} rubLine={preview ? `₽ ${num(preview.linePriceRub, 0)}` : '--'}
multiplier={multiplier} discountRate={discountRate}
fxRate={fxRate} fxRate={fxRate}
cnyGap={preview ? discountGap : null} cnyGap={preview ? discountGap : null}
rubGap={preview ? preview.linePriceRub - preview.sellingPriceRub : 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 WATERMARK_STORAGE_KEY = 'trialWatermark';
const hasCompleteDimensions = (dimensions: ProductDimensions) => const hasAnyDimensions = (dimensions: ProductDimensions) =>
[dimensions.length, dimensions.width, dimensions.height].every((value) => typeof value === 'number' && value > 0); [dimensions.length, dimensions.width, dimensions.height].some((value) => typeof value === 'number' && value > 0);
/** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */ /** 方案数量步进器:左减号 / 中间可输入 / 右加号(0-5) */
function PlanStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) { 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 [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false); const [generating, setGenerating] = useState(false);
/** 同一商品可并行生成多套图:这里保留所有仍在运行的任务,避免新任务挤掉旧任务。 */
const [activeSuiteTasks, setActiveSuiteTasks] = useState<SuiteInfo[]>([]);
const [exportingResult, setExportingResult] = useState(false); const [exportingResult, setExportingResult] = useState(false);
const [exportingImages, setExportingImages] = 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 [downloadingResultKeys, setDownloadingResultKeys] = useState<Set<string>>(() => new Set());
const [applyingWatermark, setApplyingWatermark] = useState(false); const [applyingWatermark, setApplyingWatermark] = useState(false);
const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null); const [watermarkJob, setWatermarkJob] = useState<SuiteInfo | null>(null);
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const suitePollersRef = useRef(new Map<string, { timer: ReturnType<typeof setTimeout> | null; controller: AbortController }>());
const pollAbortRef = useRef<AbortController | null>(null); const activeSuiteIdsRef = useRef(new Set<string>());
const pollRunRef = useRef(0); /** 当前展示的任务;其他任务继续轮询,但不能把当前结果面板覆盖掉。 */
const focusedSuiteIdRef = useRef<string | null>(null);
const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const watermarkPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const watermarkPollAbortRef = useRef<AbortController | null>(null); const watermarkPollAbortRef = useRef<AbortController | null>(null);
const watermarkPollRunRef = useRef(0); const watermarkPollRunRef = useRef(0);
@@ -237,6 +240,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}, [generatedAssets, hiddenResultKeys, suite]); }, [generatedAssets, hiddenResultKeys, suite]);
const suiteResultImages = resultImages.filter((img) => img.source === 'suite'); const suiteResultImages = resultImages.filter((img) => img.source === 'suite');
const historyResultImages = resultImages.filter((img) => img.source === 'history'); 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 selectedResultCount = resultImages.filter((img) => selectedResultKeys.has(img.key)).length;
const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId); const selectedWatermarkItems = resultImages.filter((img) => selectedResultKeys.has(img.key) && img.assetId);
const selectedWatermarkAssetIds = Array.from(new Set(selectedWatermarkItems.map((img) => 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assets]); }, [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 = () => { const stopPolling = () => {
pollRunRef.current += 1; Array.from(suitePollersRef.current.keys()).forEach(stopSuitePolling);
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
pollTimerRef.current = null;
pollAbortRef.current?.abort();
pollAbortRef.current = null;
}; };
const toggleKey = (key: string) => { 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 currentStylePrompt = stylePrompts[styleSet] ?? STYLE_SET_OPTIONS.find((s) => s.value === styleSet)?.prompt ?? '';
const totalPlanned = plan.reduce((s, i) => s + i.count, 0); const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
const pollSuite = (suiteId: string) => { const pollSuite = (suiteId: string, focus = true) => {
stopPolling(); // 只替换同一任务的轮询,绝不停止其他正在运行的套图任务。
const runId = ++pollRunRef.current; stopSuitePolling(suiteId);
const controller = new AbortController(); 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(); const startedAt = Date.now();
let failCount = 0; let failCount = 0;
let timeoutWarned = false; let timeoutWarned = false;
const storageKey = `trialSuiteTasks:${product.id}`;
const legacyStorageKey = `trialSuiteTask:${product.id}`;
try { 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 { /* 私密模式等不可写时不影响当前轮询 */ } } catch { /* 私密模式等不可写时不影响当前轮询 */ }
const clearSavedTask = () => { const clearSavedTask = () => {
try { 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 不可用 */ } } catch { /* 忽略 sessionStorage 不可用 */ }
}; };
const finish = () => { const finish = () => {
if (runId !== pollRunRef.current) return; if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
if (pollTimerRef.current) clearTimeout(pollTimerRef.current); stopSuitePolling(suiteId);
pollTimerRef.current = null; activeSuiteIdsRef.current.delete(suiteId);
pollAbortRef.current = null; setActiveSuiteTasks((previous) => previous.filter((task) => task.id !== suiteId));
setGenerating(false); setGenerating(activeSuiteIdsRef.current.size > 0);
}; };
const schedule = (delayMs: number) => { const schedule = (delayMs: number) => {
pollTimerRef.current = setTimeout(() => void poll(), delayMs); poller.timer = setTimeout(() => void poll(), delayMs);
}; };
const poll = async () => { const poll = async () => {
if (runId !== pollRunRef.current || controller.signal.aborted) return; if (suitePollersRef.current.get(suiteId)?.controller !== controller || controller.signal.aborted) return;
try { try {
const s = await getSuite(suiteId, { signal: controller.signal }); const s = await getSuite(suiteId, { signal: controller.signal });
if (runId !== pollRunRef.current) return; if (suitePollersRef.current.get(suiteId)?.controller !== controller) return;
setSuite(s); 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; failCount = 0;
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断 // 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000; const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
@@ -397,7 +425,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
// 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。 // 仅在上一轮请求完成后安排下一轮,避免慢网络产生重叠请求。
schedule(3000); schedule(3000);
} catch { } catch {
if (controller.signal.aborted || runId !== pollRunRef.current) return; if (controller.signal.aborted || suitePollersRef.current.get(suiteId)?.controller !== controller) return;
failCount += 1; failCount += 1;
if (failCount >= 3) { if (failCount >= 3) {
finish(); finish();
@@ -479,25 +507,41 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
schedule(0); schedule(0);
}; };
// 刷新试算页后自动恢复当前商品仍在运行的套图任务;服务重启导致的任务丢失会按常规错误提示处理。 // 刷新试算页后恢复当前商品的所有运行任务,而不是仅恢复最新一条。
useEffect(() => { useEffect(() => {
let savedTaskId = ''; let savedTaskIds: string[] = [];
try { 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 不可用时跳过恢复 */ } } catch { /* sessionStorage 不可用时跳过恢复 */ }
if (savedTaskId) { if (savedTaskIds.length > 0) {
setGenerating(true); void Promise.all(savedTaskIds.map((id) => getSuite(id).catch(() => null))).then((tasks) => {
pollSuite(savedTaskId); 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 { } else {
void listProductSuites(product.id, true).then((active) => { void listProductSuites(product.id, true).then((active) => {
const task = active[0]; if (active.length === 0) return;
if (!task) return; focusedSuiteIdRef.current = active[0].id;
setSuite(task); setSuite(active[0]);
setGenerating(true); active.forEach((task, index) => pollSuite(task.id, index === 0));
pollSuite(task.id);
}).catch(() => { /* 网络失败时不打断试算页使用 */ }); }).catch(() => { /* 网络失败时不打断试算页使用 */ });
} }
return () => stopPolling(); return () => {
stopPolling();
activeSuiteIdsRef.current.clear();
setActiveSuiteTasks([]);
setGenerating(false);
};
// 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。 // 只在商品切换/卸载时恢复或停止;pollSuite 使用当前商品 ID 写入任务 key。
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [product.id]); }, [product.id]);
@@ -535,11 +579,12 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
message.warning('出图方案的张数都是 0'); message.warning('出图方案的张数都是 0');
return; return;
} }
if (activePlan.some((item) => item.kind === 'size_chart') && !hasCompleteDimensions(dimensions)) { if (activePlan.some((item) => item.kind === 'size_chart') && !hasAnyDimensions(dimensions)) {
message.warning('方案包含尺寸标注图,请先在右侧完整填写商品长、宽、高及单位,或将该项数量设为 0'); message.warning('方案包含尺寸标注图,请先在右侧填写至少一项商品尺寸及单位,或将该项数量设为 0');
return; return;
} }
setGenerating(true); setGenerating(true);
focusedSuiteIdRef.current = null;
setSuite(null); setSuite(null);
try { try {
const { suite_id } = await generateSuite({ const { suite_id } = await generateSuite({
@@ -551,7 +596,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
style_set: styleSet, style_set: styleSet,
style_prompt: currentStylePrompt || null, style_prompt: currentStylePrompt || null,
requirements: requirements.trim() || null, requirements: requirements.trim() || null,
dimensions: hasCompleteDimensions(dimensions) ? dimensions : null, dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
plan: activePlan, plan: activePlan,
platform: 'ozon', platform: 'ozon',
model, model,
@@ -570,9 +615,18 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
try { try {
const next = await cancelSuite(suite.id); const next = await cancelSuite(suite.id);
setSuite(next); setSuite(next);
stopPolling(); stopSuitePolling(suite.id);
setGenerating(false); activeSuiteIdsRef.current.delete(suite.id);
try { sessionStorage.removeItem(`trialSuiteTask:${product.id}`); } catch { /* ignore */ } 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('已取消生成任务;已完成的图片会保留在生成记录中'); message.info('已取消生成任务;已完成的图片会保留在生成记录中');
} catch (e) { } catch (e) {
message.error(apiErrorMessage(e)); message.error(apiErrorMessage(e));
@@ -650,7 +704,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
image_stats: stats, image_stats: stats,
platform: 'ozon', platform: 'ozon',
requirements: requirements.trim() || null, requirements: requirements.trim() || null,
dimensions: hasCompleteDimensions(dimensions) ? dimensions : null, dimensions: hasAnyDimensions(dimensions) ? dimensions : null,
}); });
if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应 if (seq !== planSeqRef.current) return; // 已被重置,丢弃过期响应
setPlan(data.items); setPlan(data.items);
@@ -696,7 +750,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
}); });
}; };
// ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,分组建文件夹) ── // ── 导出勾选的生图结果(套图成功图 + 单张 AI 生图回写图,统一放在 ZIP 根目录) ──
const handleExportResult = async () => { const handleExportResult = async () => {
if (resultImages.length === 0) { if (resultImages.length === 0) {
message.warning('暂无生图结果可导出'); message.warning('暂无生图结果可导出');
@@ -715,7 +769,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
variantName: img.variantName, variantName: img.variantName,
key: img.key, 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`); downloadBlob(blob, `${cleanFilename(productTitle) || '生图'}-生图结果.zip`);
} catch (e) { } catch (e) {
message.error(apiErrorMessage(e)); message.error(apiErrorMessage(e));
@@ -1144,7 +1198,7 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
/> />
</div> </div>
<div style={{ marginBottom: 8 }}> <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 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 4 }}>
{(['length', 'width', 'height'] as const).map((key, index) => ( {(['length', 'width', 'height'] as const).map((key, index) => (
<Fragment key={key}> <Fragment key={key}>
@@ -1253,6 +1307,30 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
<Empty description="生成后在此查看与导出(目标规格:俄文文案 · 3:4 图片)" /> <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 && ( {suite && (
<> <>
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
@@ -1356,11 +1434,14 @@ export default function TrialSuitePanel({ product, assets, onRefreshAssets }: Pr
open={genModal !== null} open={genModal !== null}
productId={product.id} productId={product.id}
source={genModal} 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} watermark={watermark.enabled ? watermark : undefined}
onClose={() => setGenModal(null)} onClose={() => setGenModal(null)}
onGenerated={() => onRefreshAssets()} onGenerated={() => onRefreshAssets()}
onSubmitted={(suiteId) => { onSubmitted={(suiteId) => {
setGenerating(true);
pollSuite(suiteId); 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 { Alert, App, Button, Card, Empty, Image, Modal, Popconfirm, Space, Tag, Typography } from 'antd';
import type { ProductAsset } from '@/services/product'; import type { ProductAsset } from '@/services/product';
import { apiErrorMessage } from '@/services/api'; 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 { Text } = Typography;
const urlOf = (asset: ProductAsset) => asset.stored_url || asset.source_url; 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 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'; 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; job: VideoJobInfo;
removing: boolean; removing: boolean;
downloading: boolean;
onCancel: (id: string) => void; onCancel: (id: string) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onRetry: (id: string) => void; onRetry: (id: string) => void;
onDownload: (id: string) => void;
}) { }) {
const active = ['queued', 'running'].includes(job.status); const active = ['queued', 'running'].includes(job.status);
const done = job.status === 'done' && !!job.result?.url; 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 }}> <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> <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> <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>} {failed && <Button type="link" size="small" icon={<ReloadOutlined />} onClick={() => onRetry(job.id)} style={{ marginLeft: 'auto', paddingInline: 0, fontSize: 13, whiteSpace: 'nowrap' }}>重试</Button>}
</div> </div>
{job.error && <Text type="danger" ellipsis={{ tooltip: job.error }} style={{ display: 'block', fontSize: 12, marginTop: 5 }}>{job.error}</Text>} {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 [selected, setSelected] = useState<string[]>([]);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [removingId, setRemovingId] = useState<string | null>(null); const [removingId, setRemovingId] = useState<string | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
const [jobs, setJobs] = useState<VideoJobInfo[]>([]); 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]); 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); } 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 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> return <div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}> <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> <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> <Button type="primary" size="small" icon={<VideoCameraOutlined />} disabled={candidates.length < 2} onClick={() => setOpen(true)}>合成视频</Button>
</div> </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="选择轮播图片"> <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 }} /> <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>} {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)。 * 计价纯函数 —— 从 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 weightG: number; // 重量 g
dims: { l: number; w: number; h: number }; // cm dims: { l: number; w: number; h: number }; // cm
tdPrice: number; // 贴单费用 ¥ tdPrice: number; // 贴单费用 ¥
lineMultiplier: number; // 划线价倍数 %(划线价 = 销售价 × (1 + 倍数/100)) lineDiscountRate: number; // 划线折扣率 %(划线价 = 销售价 ÷ (1 - 折扣率/100))
fxRate: number; // 汇率 CNY→RUB fxRate: number; // 汇率 CNY→RUB
} }
@@ -28,7 +28,7 @@ export interface PricingResult {
commission: number; // 展示用平台佣金 commission: number; // 展示用平台佣金
totalCost: number; // 完全成本 totalCost: number; // 完全成本
sellingPriceRub: number; // 销售价 ₽ sellingPriceRub: number; // 销售价 ₽
linePriceCny: number; // 划线价 ¥(销售价 × (1 + 倍数)) linePriceCny: number; // 划线价 ¥
linePriceRub: number; // 划线价 ₽ linePriceRub: number; // 划线价 ₽
} }
@@ -45,7 +45,10 @@ export function baseLogisticsFee(weightG: number, level: LogisticsLevel): number
} }
export function calculatePricing(input: PricingInput): PricingResult { 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 logisticsFee = baseLogisticsFee(weightG, logisticsLevel) + tdPrice;
const profitDecimal = profitRate / 100; const profitDecimal = profitRate / 100;
@@ -58,7 +61,7 @@ export function calculatePricing(input: PricingInput): PricingResult {
const totalCost = purchasePrice + logisticsFee + fullCommission; const totalCost = purchasePrice + logisticsFee + fullCommission;
const sellingPriceRub = sellingPriceCny * fxRate; const sellingPriceRub = sellingPriceCny * fxRate;
const linePriceCny = sellingPriceCny * (1 + lineMultiplier / 100); const linePriceCny = sellingPriceCny / (1 - lineDiscountRate / 100);
const linePriceRub = linePriceCny * fxRate; const linePriceRub = linePriceCny * fxRate;
return { 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 { export function validateLogisticsLevel(sellingPriceRub: number, level: LogisticsLevel): string {
if (sellingPriceRub > 140 && level === 'low') return '售价较高,建议选高等级物流'; 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 { export interface ImageEditSinglePayload {
product_id?: string; product_id?: string;
image_url: string; /** 兼容旧接口的单张底图;新界面使用 image_urls。 */
image_url?: string;
/** 选中的参考底图,可多张。 */
image_urls?: string[];
prompt?: string; prompt?: string;
/** edit 为常规 AI 编辑;outpaint 只允许生成扩展到 3:4 的新增边缘。 */
mode?: 'edit' | 'outpaint';
model: string; model: string;
append?: boolean; append?: boolean;
/** 插入锚点:新素材排在该素材之后(方便与原图对比) */ /** 插入锚点:新素材排在该素材之后(方便与原图对比) */
@@ -254,6 +259,8 @@ export function listProductWatermarkJobs(productId: string, activeOnly = false,
export interface ExportImagesPayload { export interface ExportImagesPayload {
title: string; title: string;
images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>; images: Array<{ url: string; groupName: string; variantName?: string | null; key: string }>;
/** 生成结果导出时忽略图片分组,统一放在 ZIP 的商品根目录。 */
flat?: boolean;
} }
/** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */ /** ★ 导出采集图片 ZIP(分组建文件夹,服务端代理下载绕防盗链) */
@@ -321,6 +328,8 @@ export function deleteVideoJob(jobId: string) {
return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`); return api.delete<{ deleted: boolean }>(`/video-jobs/${jobId}`);
} }
export function videoDownloadUrl(jobId: string) { /** 下载视频为 Blob,避免浏览器把 MP4 当作页面打开或预览。 */
return `/api/video-jobs/${jobId}/download`; 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"}