feat: 添加新的模型,删除后端数据库

This commit is contained in:
Joey
2026-08-19 22:37:20 +08:00
parent 82cb694837
commit 6732cb178a
17 changed files with 411 additions and 918 deletions
+47 -8
View File
@@ -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
View File
@@ -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 }>;