feat: 添加新的模型,删除后端数据库
This commit is contained in:
@@ -15,7 +15,7 @@ import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined,
|
||||
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
||||
import {
|
||||
buildGeneratePayload, suiteZipUrl, uploadImage,
|
||||
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
|
||||
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS, WEAK_FIDELITY_MODELS,
|
||||
type PlanItem, type SuiteInfo,
|
||||
} from '../../src/api/client';
|
||||
import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings';
|
||||
@@ -280,10 +280,24 @@ const App: React.FC = () => {
|
||||
|
||||
const pollSuite = useCallback((suiteId: string) => {
|
||||
stopPolling();
|
||||
const startedAt = Date.now();
|
||||
let failCount = 0; // 连续轮询失败计数(容忍瞬时抖动,连续 3 次才判定后端不可达)
|
||||
let timeoutWarned = false;
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const s = await send<SuiteInfo>('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId });
|
||||
setSuite(s);
|
||||
failCount = 0;
|
||||
// 超时兜底:单张最长约 5 分钟,超预算仍 running 多半是任务已中断(如后端重启)
|
||||
const budgetMs = ((s.total ?? 1) * 5 + 10) * 60_000;
|
||||
if (!timeoutWarned && ['running', 'pending'].includes(s.status) && Date.now() - startedAt > budgetMs) {
|
||||
timeoutWarned = true;
|
||||
modal.warning({
|
||||
title: '任务耗时异常',
|
||||
content: '生成耗时远超预期,任务可能已中断。可稍等片刻观察,或刷新页面后重新生成。',
|
||||
okText: '知道了',
|
||||
});
|
||||
}
|
||||
if (['done', 'partial', 'failed'].includes(s.status)) {
|
||||
stopPolling();
|
||||
setGenerating(false);
|
||||
@@ -291,8 +305,16 @@ const App: React.FC = () => {
|
||||
if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' });
|
||||
}
|
||||
} catch (e) {
|
||||
stopPolling();
|
||||
setGenerating(false);
|
||||
failCount += 1;
|
||||
if (failCount >= 3) {
|
||||
stopPolling();
|
||||
setGenerating(false);
|
||||
modal.error({
|
||||
title: '无法连接后端',
|
||||
content: '连续查询生成进度失败,已停止跟踪。若刚重启过后端(./start.command),任务已被中断,请重新生成。',
|
||||
okText: '知道了',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}, [settings.baseUrl, settings.token, modal]);
|
||||
@@ -396,7 +418,8 @@ const App: React.FC = () => {
|
||||
|
||||
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
|
||||
const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0;
|
||||
const suiteTotal = suite?.images.length ?? totalPlanned;
|
||||
// 分母用后端返回的计划总张数:images 是逐张落库的,生成中 length 永远等于已完成数
|
||||
const suiteTotal = suite?.total ?? totalPlanned;
|
||||
/** 当前风格生效的提示词:用户改写值 > 该风格默认值 */
|
||||
const currentStylePrompt = stylePrompts[styleSet]
|
||||
?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? '';
|
||||
@@ -408,7 +431,21 @@ const App: React.FC = () => {
|
||||
const spec = PLATFORM_SPECS[platform];
|
||||
modal.confirm({
|
||||
title: '生成电商套图',
|
||||
content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`,
|
||||
content: (
|
||||
<div>
|
||||
<div>
|
||||
目标平台「{spec.label}」({spec.lang}文案 · {spec.ratio}),模型「{model}」,风格「
|
||||
{STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 {totalPlanned} 张、参考图{' '}
|
||||
{selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。
|
||||
</div>
|
||||
{WEAK_FIDELITY_MODELS.has(model) && (
|
||||
<div style={{ marginTop: 8, color: '#d4380d', fontWeight: 600 }}>
|
||||
⚠️ 「{model}」为官逆通道,商品还原度不稳定,可能生成与原商品不符的图片。建议先只出 1
|
||||
张确认效果,正式套图请改用 gpt-image-2。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
okText: '开始生成', cancelText: '取消',
|
||||
onOk: () => startGenerate(),
|
||||
});
|
||||
@@ -796,10 +833,11 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
|
||||
{generating && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ flex: 6 }}>
|
||||
{/* 进度条与右侧弹性占位 6:1,长度 = 剩余空间的 6/7(两次加长后的累积比例) */}
|
||||
<Progress
|
||||
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
|
||||
size="small" status="active" format={() => `${doneCount}/${suiteTotal}`}
|
||||
size={['100%', 10]} status="active" format={() => `${doneCount}/${suiteTotal}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -822,6 +860,7 @@ const App: React.FC = () => {
|
||||
</Select>
|
||||
<button
|
||||
className="btn btn-primary btn-main"
|
||||
style={{ alignSelf: 'stretch' }}
|
||||
disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
@@ -852,7 +891,7 @@ const App: React.FC = () => {
|
||||
</b>
|
||||
{' '}· {PLATFORM_SPECS[suite.platform]?.label ?? suite.platform}
|
||||
{' '}· {PLATFORM_SPECS[suite.platform]?.lang ?? suite.lang}文案 · {suite.ratio}
|
||||
{' '}· 风格「{STYLE_SET_OPTIONS.find(s => s.value === suite.style_set)?.label}」
|
||||
{' '}· 风格「{STYLE_SET_OPTIONS.find(s => s.value === suite.style_set)?.label ?? '自定义'}」
|
||||
</div>
|
||||
<div className="result-grid">
|
||||
{suite.images.map(img => (
|
||||
|
||||
+44
-109
@@ -1,29 +1,8 @@
|
||||
/**
|
||||
* 后端 HTTP 客户端 —— 仅 background 使用(有 host_permissions,不受 CORS 约束)。
|
||||
* 契约对齐 server 端 /api/materials 与 /api/suites。
|
||||
* 契约对齐 server 端 /api/plan、/api/generate 与 /api/suites。
|
||||
*/
|
||||
import type { ScanResult, ImageMaterial } from '../collector/scan';
|
||||
|
||||
export interface MaterialsPayload {
|
||||
product_id: string | null;
|
||||
source: {
|
||||
platform: string;
|
||||
itemId: string | null;
|
||||
url: string;
|
||||
collectedAt: number;
|
||||
};
|
||||
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||
images: Array<{
|
||||
groupKey: string;
|
||||
groupName: string;
|
||||
variantName?: string | null;
|
||||
url: string;
|
||||
index: number;
|
||||
type: string;
|
||||
dedupeKey?: string | null;
|
||||
}>;
|
||||
refererOrigin?: string;
|
||||
}
|
||||
import type { ImageMaterial } from '../collector/scan';
|
||||
|
||||
/** 服务端支持的套图类型(与 server/services/prompt.py 保持一致) */
|
||||
export const SUITE_TYPE_OPTIONS = [
|
||||
@@ -58,41 +37,37 @@ export const DEFAULT_PLAN: PlanItem[] = SUITE_TYPE_OPTIONS.slice(0, 7).map(t =>
|
||||
export const STYLE_SET_OPTIONS = [
|
||||
{
|
||||
value: 1,
|
||||
label: '高级质感大片',
|
||||
prompt: '高端电商大片质感,柔和的方向性棚拍光,背景带细腻的浅渐变,材质纹理清晰可见,色彩层次高级克制,商业画册级品质,构图干净、留白充足',
|
||||
label: '北欧极简',
|
||||
prompt: '北欧极简风:浅灰或米白背景,柔和漫射光,低饱和色调,画面留白充足,构图克制干净',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '清新生活场景',
|
||||
prompt: '明亮通透的生活场景摄影,自然窗光,柔和的低饱和居家环境,浅景深虚化,真实自然的氛围感,绿植与暖色织物点缀,温馨有人气',
|
||||
label: '清新明亮',
|
||||
prompt: '清新明亮风:明亮的白色到浅蓝渐变背景,高调光线,色彩明快通透,整体轻盈干净',
|
||||
},
|
||||
{
|
||||
value: 3,
|
||||
label: '极简白底规范',
|
||||
prompt: '极简棚拍风格,纯净无缝的浅色背景,柔和均匀的无影布光,以商品为中心的严谨构图,安静的高级感,画面只保留轻微的自然接触投影',
|
||||
label: '高级感深色',
|
||||
prompt: '高级质感风:深灰或炭黑背景,戏剧性侧光打光,突出商品材质与光泽,沉稳高级',
|
||||
},
|
||||
{
|
||||
value: 4,
|
||||
label: '炫彩促销风',
|
||||
prompt: '高能量促销风格,高饱和度色块背景搭配动感几何图形,强对比,节日大促海报氛围,构图抢眼、视觉冲击力强',
|
||||
label: '暖调生活',
|
||||
prompt: '温暖生活风:暖米色背景,暖色灯光氛围,温馨的家居质感,亲和力强',
|
||||
},
|
||||
{
|
||||
value: 5,
|
||||
label: '暗调轻奢',
|
||||
prompt: '暗调轻奢质感,深炭灰色背景,轮廓光勾勒商品边缘,材质细节丰富,带轻微雾感,如美术馆展陈般的呈现',
|
||||
},
|
||||
{
|
||||
value: 6,
|
||||
label: '俄式风情',
|
||||
prompt: '俄式风情电商大片,浓郁温暖的色调,红与金的传统配色点缀,冬日节庆氛围,深色木质与毛毡织物背景,如暖炉烛光般的柔和光晕,厚重扎实的质感,带一丝巴洛克式的华丽细节,适合俄语区市场',
|
||||
},
|
||||
{
|
||||
value: 7,
|
||||
label: '北欧极简',
|
||||
prompt: '北欧极简风格,白色与浅灰的原木空间,大量自然漫射光,干净利落的线条,浅色木质背景点缀少量绿植,克制的中性配色,画面通透轻盈,舒适宁静的氛围',
|
||||
label: '纯净棚拍',
|
||||
prompt: '标准电商棚拍:纯色浅背景,均匀的正面柔光,无杂物干扰,商品居中突出',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 官逆/弱保真通道模型:链路不透传保真参数(input_fidelity 等)、参考图被弱化,
|
||||
* 商品还原度不稳定,生成前需警示用户。链路性质决定,prompt 只能缓解不能根除。
|
||||
*/
|
||||
export const WEAK_FIDELITY_MODELS = new Set(['gpt-image-2-vip']);
|
||||
|
||||
/** 目标平台(决定文案语言 + 图片比例):Ozon/Wildberries → 俄文 3:4,中文 → 中文 1:1 */
|
||||
export const PLATFORM_OPTIONS = [
|
||||
{ value: 'ozon', label: 'Ozon' },
|
||||
@@ -129,6 +104,31 @@ export const IMAGE_MODEL_OPTIONS = [
|
||||
label: 'gpt-image-2',
|
||||
desc: 'GPT 图像模型,构图与图内文案渲染最强,参考图高保真,单张 1-5 分钟',
|
||||
},
|
||||
{
|
||||
value: 'gpt-image-2-vip',
|
||||
label: 'gpt-image-2-vip',
|
||||
desc: 'GPT 官逆低价通道,构图与文字渲染强,但商品还原不稳定(官逆链路限制),试错可用,正式出图建议 gpt-image-2',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana',
|
||||
label: 'nano-banana',
|
||||
desc: 'Google Gemini 图像模型,出图极快,图像编辑与风格迁移强,多图融合自然',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-2',
|
||||
label: 'nano-banana-2',
|
||||
desc: 'Google 新一代图像模型,画质与文字渲染大幅提升,日常生成与改图的综合首选',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-2-lite',
|
||||
label: 'nano-banana-2-lite',
|
||||
desc: 'nano-banana-2 轻量版,约 4 秒/张、成本极低,适合大批量出图与快速试错',
|
||||
},
|
||||
{
|
||||
value: 'nano-banana-pro',
|
||||
label: 'nano-banana-pro',
|
||||
desc: 'Google 最高保真旗舰,细节最强、支持 4K 输出,适合商业级精修大片',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const PLATFORM_SPECS: Record<string, { lang: string; ratio: string; label: string }> = {
|
||||
@@ -147,86 +147,21 @@ export interface SuiteImageInfo {
|
||||
|
||||
export interface SuiteInfo {
|
||||
id: string;
|
||||
product_id: string;
|
||||
status: 'pending' | 'running' | 'done' | 'partial' | 'failed';
|
||||
style_set: number;
|
||||
platform: string;
|
||||
lang: string;
|
||||
ratio: string;
|
||||
types: string[];
|
||||
provider: string;
|
||||
total?: number; // 计划生成总张数(后端返回;images 逐张追加,过程中 length < total)
|
||||
images: SuiteImageInfo[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
/** 用(可能已二次修改的)文本 + 已勾选图片,组装 /api/materials 请求体 */
|
||||
export function buildMaterialsPayload(
|
||||
result: ScanResult,
|
||||
selectedKeys: Set<string>,
|
||||
edits?: { title?: string; desc?: string },
|
||||
): MaterialsPayload {
|
||||
const orig = (kind: string) => result.texts.find((t) => t.kind === kind);
|
||||
|
||||
const texts: MaterialsPayload['texts'] = [];
|
||||
const title = edits?.title ?? orig('title')?.content ?? '';
|
||||
const price = orig('price')?.content ?? '';
|
||||
const brand = orig('brand')?.content ?? '';
|
||||
const params = orig('params')?.pairs ?? [];
|
||||
const sellingPoints = orig('selling_point')?.content ?? '';
|
||||
const desc = edits?.desc ?? orig('desc')?.content ?? '';
|
||||
|
||||
if (title) texts.push({ kind: 'title', content: title });
|
||||
if (price) texts.push({ kind: 'price', content: price });
|
||||
if (brand) texts.push({ kind: 'brand', content: brand });
|
||||
if (params.length) texts.push({ kind: 'params', content: '', pairs: params });
|
||||
if (sellingPoints) texts.push({ kind: 'selling_point', content: sellingPoints });
|
||||
if (desc) texts.push({ kind: 'desc', content: desc });
|
||||
|
||||
const images = result.images
|
||||
.filter((img) => selectedKeys.has(img.key))
|
||||
.map((img) => ({
|
||||
groupKey: img.groupKey,
|
||||
groupName: img.groupName,
|
||||
variantName: img.variantName ?? null,
|
||||
url: img.url,
|
||||
index: img.index,
|
||||
type: img.type,
|
||||
dedupeKey: img.url,
|
||||
}));
|
||||
|
||||
return {
|
||||
product_id: null,
|
||||
source: {
|
||||
platform: result.platform,
|
||||
itemId: result.itemId,
|
||||
url: result.url,
|
||||
collectedAt: result.scannedAt,
|
||||
},
|
||||
texts,
|
||||
images,
|
||||
refererOrigin: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function authHeaders(token: string): Record<string, string> {
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
export async function uploadMaterials(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
payload: MaterialsPayload,
|
||||
): Promise<{ product_id: string; assets_queued: number; assets_skipped: number }> {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/api/materials`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders(token) },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.detail || `上传失败 HTTP ${res.status}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** 无状态生成请求体:采集数据 + 勾选图片 + 出图方案,一次携带 */
|
||||
export interface GeneratePayload {
|
||||
texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }>;
|
||||
|
||||
Reference in New Issue
Block a user