Files
image-suite-studio/extension/entrypoints/sidepanel/App.tsx
T

972 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 电商套图工作台 - Side Panel
*
* 布局:顶栏 + 目标平台
* ├─ 左 01 商品信息 | 右 02 采集图片(两列等高,采集阶段)
* ├─ 03 出图方案(整行:方案列表 + AI 智能规划 + 风格 + 一键生成)
* └─ 04 生成结果(整行)
*
* 流程:采集页面 → 编辑/勾选 → AI 规划或用默认方案 → 一键生成 → 导出 ZIP
*/
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { App as AntApp, ConfigProvider, Popover, Progress, Select } from 'antd';
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined, UploadOutlined, CloseOutlined } from '@ant-design/icons';
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import {
buildGeneratePayload, suiteZipUrl, uploadImage,
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';
/** 数据来源 → 展示含义 */
const SOURCE_LABELS: Record<string, string> = {
state: '页面数据',
ssr: '页面数据',
jsonld: '结构化数据',
api: '站内接口',
dom: 'DOM解析',
mixed: '混合来源',
};
/** 平台 → 展示名 */
const PLATFORM_LABELS: Record<string, string> = {
ozon: 'Ozon',
'1688': '1688',
taobao: '淘宝/天猫',
};
/** 支持的站点(用于判断刷新是否有意义) */
const SUPPORTED_URL_RE = /^https?:\/\/([a-z0-9-]+\.ozon\.(ru|kz|by)|detail\.1688\.com|item\.taobao\.com|detail\.tmall\.com)\//i;
/** 页内悬浮面板模式(作为 iframe 嵌在商品页里运行);独立 Side Panel 打开时为 false */
const IN_PAGE = window.self !== window.top;
/** 通知宿主页收起悬浮面板 */
function closePanel(): void {
window.parent.postMessage({ type: 'sc-panel-close' }, '*');
}
/** 在指定 tab 执行采集,返回结果或 null(未就绪/不支持) */
async function tryScan(tabId: number): Promise<ScanResult | null> {
const [res] = await chrome.scripting.executeScript({
target: { tabId },
func: () => (window as any).__SuiteCollector?.scan?.() ?? null,
});
return (res?.result ?? null) as ScanResult | null;
}
/** 等待 tab 加载完成(需在 reload 前注册监听),超时放行 */
function waitForTabComplete(tabId: number, timeoutMs = 30_000): Promise<void> {
return new Promise((resolve) => {
const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(); }, timeoutMs);
function listener(id: number, info: chrome.tabs.TabChangeInfo) {
if (id === tabId && info.status === 'complete') {
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
}
}
chrome.tabs.onUpdated.addListener(listener);
});
}
function send<T>(action: string, payload: Record<string, unknown>): Promise<T> {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage({ action, ...payload }, (res: { ok: boolean; data?: T; error?: string }) => {
if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
if (!res?.ok) return reject(new Error(res?.error || '后台请求失败'));
resolve(res.data as T);
});
});
}
// ── 小组件 ─────────────────────────────────────────────────────────────────
const Section: React.FC<{
no: string; title: string; extra?: React.ReactNode; children: React.ReactNode; className?: string;
}> =
({ no, title, extra, children, className }) => (
<div className={`section ${className ?? ''}`}>
<div className="section-head">
<span className="section-no">{no}</span>
<span className="section-title">{title}</span>
{extra && <span className="section-extra">{extra}</span>}
</div>
{children}
</div>
);
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<div className="field">
<label>{label}</label>
{children}
</div>
);
/** 数量加减器 */
const Stepper: React.FC<{ value: number; onChange: (v: number) => void }> = ({ value, onChange }) => (
<span className="stepper">
<button className="step-btn" disabled={value <= 0} onClick={() => onChange(Math.max(0, value - 1))}></button>
<span className="step-num">{value}</span>
<button className="step-btn" disabled={value >= 5} onClick={() => onChange(Math.min(5, value + 1))}></button>
</span>
);
const App: React.FC = () => {
const { modal } = AntApp.useApp();
// 采集
const [scanning, setScanning] = useState(false);
const [retrying, setRetrying] = useState(false);
const [result, setResult] = useState<ScanResult | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [titleEdit, setTitleEdit] = useState('');
const [priceEdit, setPriceEdit] = useState('');
const [dimsEdit, setDimsEdit] = useState<{ l: string; w: string; h: string }>({ l: '', w: '', h: '' });
const [weightEdit, setWeightEdit] = useState('');
const [descEdit, setDescEdit] = useState('');
const [paramsOpen, setParamsOpen] = useState(false);
// 手动上传图片(补充参考图,独立「upload」分组)
const [uploadedImages, setUploadedImages] = useState<ImageMaterial[]>([]);
const [uploading, setUploading] = useState(false);
const fileRef = useRef<HTMLInputElement | null>(null);
const uploadSeqRef = useRef(0);
// 服务端
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '' });
// 出图方案
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('ozon');
const [styleSet, setStyleSet] = useState(1);
/** 生图模型(默认 gpt-image-2,走 RightAPI provider */
const [model, setModel] = useState<string>('gpt-image-2');
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
/** 生图要求(最高优先级,强制约束,覆盖其他设定) */
const [requirements, setRequirements] = useState('');
const [plan, setPlan] = useState<PlanItem[]>(DEFAULT_PLAN.map(p => ({ ...p })));
const [planSource, setPlanSource] = useState<'default' | 'ai'>('default');
const [planSummary, setPlanSummary] = useState('');
const [planning, setPlanning] = useState(false);
const [planThenGenerate, setPlanThenGenerate] = useState(false);
// 生成
const [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
/** 规划请求序号:重新采集时递增,用于丢弃重置后才返回的过期规划响应 */
const planSeqRef = useRef(0);
// 图片放大预览(画廊:可左右切换)
const [preview, setPreview] = useState<{ list: string[]; index: number } | null>(null);
const openPreview = (list: string[], index: number) => {
if (list.length === 0) return;
setPreview({ list, index: Math.max(0, Math.min(index, list.length - 1)) });
};
const previewPrev = () => setPreview(p => p && { ...p, index: (p.index - 1 + p.list.length) % p.list.length });
const previewNext = () => setPreview(p => p && { ...p, index: (p.index + 1) % p.list.length });
useEffect(() => {
loadSettings().then(setSettings);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
// 预览态的键盘导航:← → 切换,Esc 关闭
useEffect(() => {
if (!preview) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowLeft') previewPrev();
else if (e.key === 'ArrowRight') previewNext();
else if (e.key === 'Escape') setPreview(null);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preview]);
// 页内面板模式:Esc 收起面板(预览打开时优先关预览,由上面的监听处理)
useEffect(() => {
if (!IN_PAGE) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape' && !preview) closePanel();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [preview]);
const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? '';
const sales = result?.texts.find(t => t.kind === 'sales')?.content ?? '';
const shop = result?.texts.find(t => t.kind === 'shop')?.content ?? '';
const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? [];
const handleScan = () => {
// 生成中:二次确认(后台任务会继续完成,但本面板停止跟踪)
if (generating) {
modal.confirm({
title: '有正在生成的任务',
content: '重新采集将清空当前采集结果与出图方案,并停止在本面板跟踪正在生成的任务(已提交的后台生成会继续完成,但此处将看不到进度和导出入口)。确定要重新采集吗?',
okText: '继续采集', cancelText: '取消',
onOk: () => doScan(),
});
return;
}
doScan();
};
const doScan = async () => {
// 规划中:直接重置规划并继续采集(递增序号,迟到的规划响应会被丢弃)
if (planning) {
planSeqRef.current++;
setPlanning(false);
}
// 确认路径下停止对旧生成任务的本地跟踪(后台任务继续,不受影响)
if (generating) {
stopPolling();
setGenerating(false);
}
setScanning(true);
setRetrying(false);
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error('未找到活动标签页');
// 首次尝试
let r = await tryScan(tab.id);
// 失败:支持站点上大概率是内容脚本未就绪 → 自动刷新重试一次(仅一次,防死循环)
if (!r) {
if (!SUPPORTED_URL_RE.test(tab.url ?? '')) {
throw new Error('当前页面不是支持的商品详情页(支持 Ozon / 1688 / 淘宝 / 天猫)');
}
setRetrying(true);
const waitLoaded = waitForTabComplete(tab.id); // 先注册监听再刷新,避免错过事件
await chrome.tabs.reload(tab.id);
await waitLoaded;
await new Promise(res => setTimeout(res, 1500)); // 给内容脚本注入留时间
r = await tryScan(tab.id);
if (!r) {
throw new Error('采集失败:已自动刷新重试仍无法采集,请确认当前页面是商品详情页后手动重试');
}
}
setResult(r);
setSuite(null);
setParamsOpen(false);
// 默认全选主图 + SKU 图(SKU 图带规格名,AI 规划的 variant 绑定要用)
const keys = new Set(r.images.filter(i => i.groupKey === 'main' || i.groupKey === 'sku').map(i => i.key));
setSelectedKeys(keys);
setTitleEdit(r.texts.find(t => t.kind === 'title')?.content ?? '');
setDescEdit(r.texts.find(t => t.kind === 'desc')?.content ?? '');
setPriceEdit(r.texts.find(t => t.kind === 'price')?.content ?? '');
// 从参数表初始化尺寸/重量(编辑后随生成/规划请求覆盖回参数)
const pairs = r.texts.find(t => t.kind === 'params')?.pairs ?? [];
const dimPair = pairs.find(p => /尺寸|长宽高/i.test(p.key) && (String(p.value).match(/\d+(\.\d+)?/g) ?? []).length >= 3);
const nums = dimPair ? (String(dimPair.value).match(/\d+(\.\d+)?/g) ?? []) : [];
setDimsEdit(nums.length >= 3 ? { l: nums[0], w: nums[1], h: nums[2] } : { l: '', w: '', h: '' });
const weightPair = pairs.find(p => /重量/i.test(p.key));
setWeightEdit(weightPair ? String(weightPair.value) : '');
} catch (e) {
modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
setScanning(false);
setRetrying(false);
}
};
const stopPolling = () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
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);
if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: s.error || '可重试或更换风格重新生成', okText: '知道了' });
if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' });
}
} catch (e) {
failCount += 1;
if (failCount >= 3) {
stopPolling();
setGenerating(false);
modal.error({
title: '无法连接后端',
content: '连续查询生成进度失败,已停止跟踪。若刚重启过后端(./start.command),任务已被中断,请重新生成。',
okText: '知道了',
});
}
}
}, 3000);
}, [settings.baseUrl, settings.token, modal]);
/** 当前编辑后的文本素材(规划与生成共用;尺寸/重量编辑值覆盖回参数表) */
const editedTexts = () => {
if (!result) return [];
const orig = (kind: string) => result.texts.find(t => t.kind === kind);
let pairs = [...(orig('params')?.pairs ?? [])];
// 尺寸/重量覆盖:先移除旧的同义项,再写入编辑值(有值才写)
const dropRe = [/尺寸|长宽高/i, /重量/i];
pairs = pairs.filter(p => !dropRe.some(re => re.test(p.key)));
const { l, w, h } = dimsEdit;
if (l || w || h) pairs.push({ key: '产品尺寸', value: `${l || '?'}×${w || '?'}×${h || '?'}` });
if (weightEdit) pairs.push({ key: '重量', value: weightEdit });
const texts: Array<{ kind: string; content: string; pairs?: Array<{ key: string; value: string }> | null }> = [];
const title = titleEdit || orig('title')?.content || '';
const desc = descEdit || orig('desc')?.content || '';
if (title) texts.push({ kind: 'title', content: title });
if (priceEdit || orig('price')?.content) texts.push({ kind: 'price', content: priceEdit || orig('price')!.content });
if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content });
if (orig('sales')?.content) texts.push({ kind: 'sales', content: orig('sales')!.content });
if (orig('shop')?.content) texts.push({ kind: 'shop', content: orig('shop')!.content });
if (pairs.length) texts.push({ kind: 'params', content: '', pairs });
if (orig('selling_point')?.content) texts.push({ kind: 'selling_point', content: orig('selling_point')!.content });
if (desc) texts.push({ kind: 'desc', content: desc });
return texts;
};
/** 提交生成任务(planOverride:刚规划出来的方案,避免闭包读到旧 state) */
const startGenerate = async (planOverride?: PlanItem[]): Promise<void> => {
if (!result) return;
if (selectedKeys.size === 0) {
modal.warning({ title: '请先在采集图片区勾选参考图' });
return;
}
const activePlan = (planOverride ?? plan).filter(p => p.count > 0);
if (activePlan.length === 0) {
modal.warning({ title: '出图方案的张数都是 0' });
return;
}
setGenerating(true);
setSuite(null);
try {
const payload = buildGeneratePayload(
allImages, selectedKeys, editedTexts(),
{ style_set: styleSet, style_prompt: currentStylePrompt, requirements: requirements.trim() || null, plan: activePlan, platform, model },
);
const { suite_id } = await send<{ suite_id: string }>('generateSuite', {
baseUrl: settings.baseUrl, token: settings.token, payload,
});
pollSuite(suite_id);
} catch (e) {
setGenerating(false);
modal.error({ title: '提交失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
}
};
/** AI 智能规划出图方案;勾选「规划并生成」时规划完成自动开始生成 */
const handlePlan = async () => {
if (!result) return modal.warning({ title: '请先采集商品页' });
const seq = ++planSeqRef.current;
setPlanning(true);
try {
const skuVariants = Array.from(new Set(
result.images.filter(i => i.groupKey === 'sku' && i.variantName).map(i => i.variantName!)
));
const data = await send<{ summary: string; items: PlanItem[] }>('planSuite', {
baseUrl: settings.baseUrl, token: settings.token,
payload: {
texts: editedTexts(),
sku_variants: skuVariants,
image_stats: result.stats,
platform,
requirements: requirements.trim() || null,
},
});
if (seq !== planSeqRef.current) return; // 规划已被重新采集重置,丢弃过期响应
setPlan(data.items);
setPlanSource('ai');
setPlanSummary(data.summary);
if (planThenGenerate) {
await startGenerate(data.items); // 规划完成 → 直接生成(用刚返回的方案,不弹确认)
} else {
const total = data.items.reduce((s, i) => s + i.count, 0);
modal.info({
title: 'AI 方案已生成',
content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`,
okText: '好的',
});
}
} catch (e) {
if (seq !== planSeqRef.current) return; // 已重置,过期错误不弹窗
modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
if (seq === planSeqRef.current) setPlanning(false);
}
};
const totalPlanned = plan.reduce((s, i) => s + i.count, 0);
const doneCount = suite?.images.filter(i => i.status === 'ok').length ?? 0;
// 分母用后端返回的计划总张数:images 是逐张落库的,生成中 length 永远等于已完成数
const suiteTotal = suite?.total ?? totalPlanned;
/** 当前风格生效的提示词:用户改写值 > 该风格默认值 */
const currentStylePrompt = stylePrompts[styleSet]
?? STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.prompt ?? '';
const handleGenerate = () => {
if (!result) return;
if (selectedKeys.size === 0) return modal.warning({ title: '请先在采集图片区勾选参考图' });
if (totalPlanned === 0) return modal.warning({ title: '出图方案的张数都是 0' });
const spec = PLATFORM_SPECS[platform];
modal.confirm({
title: '生成电商套图',
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(),
});
};
const handleExport = () => {
if (!suite) return;
chrome.tabs.create({ url: suiteZipUrl(settings.baseUrl, suite.id) });
};
const toggleKey = (key: string) => {
const next = new Set(selectedKeys);
if (next.has(key)) next.delete(key); else next.add(key);
setSelectedKeys(next);
};
/** 全部可选图片:采集结果 + 手动上传(上传图单独一组「upload」) */
const allImages: ImageMaterial[] = result ? [...result.images, ...uploadedImages] : uploadedImages;
const groupImages = (groupKey: string): ImageMaterial[] =>
allImages.filter(i => i.groupKey === groupKey);
/** 预览用的全量图序列(主图→SKU→详情→上传,与展示顺序一致) */
const collectedPreviewList: string[] = (['main', 'sku', 'detail', 'upload'] as const)
.flatMap(g => groupImages(g).map(i => i.url));
/** 生成结果的预览序列(仅成功的图) */
const resultPreviewList: string[] = suite ? suite.images.filter(i => i.status === 'ok').map(i => i.url) : [];
const toggleGroup = (groupKey: string, on: boolean) => {
const next = new Set(selectedKeys);
groupImages(groupKey).forEach(i => on ? next.add(i.key) : next.delete(i.key));
setSelectedKeys(next);
};
const setPlanCount = (idx: number, count: number) => {
setPlan(prev => prev.map((p, i) => i === idx ? { ...p, count } : p));
};
/** 手动上传:点击触发隐藏的 file input */
const handleUpload = () => fileRef.current?.click();
const onFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files ?? []);
e.target.value = ''; // 清空以便再次选择同名文件
if (files.length === 0) return;
setUploading(true);
const added: ImageMaterial[] = [];
for (const f of files) {
try {
const { url } = await uploadImage(settings.baseUrl, settings.token, f);
added.push({
key: `upload-${String(++uploadSeqRef.current).padStart(3, '0')}`,
groupKey: 'upload',
groupName: '手动上传',
url,
thumbUrl: url,
index: added.length,
type: 'img',
});
} catch (err) {
modal.error({ title: '上传失败', content: `${f.name}${err instanceof Error ? err.message : String(err)}`, okText: '知道了' });
}
}
if (added.length > 0) {
setUploadedImages(prev => [...prev, ...added]);
setSelectedKeys(prev => {
const next = new Set(prev);
added.forEach(a => next.add(a.key));
return next;
});
modal.success({ title: `已上传 ${added.length} 张图片` });
}
setUploading(false);
};
/** 源站图防盗链时的兜底:走服务端图片代理 */
const proxied = (u: string) =>
`${settings.baseUrl.replace(/\/$/, '')}/api/proxy-image?url=${encodeURIComponent(u)}`;
/** 缩略图三级降级:thumbUrl → 原图 → 服务端代理 */
const onThumbError = (e: React.SyntheticEvent<HTMLImageElement>, url: string) => {
const el = e.currentTarget;
if (el.dataset.step === '1') { el.dataset.step = '2'; el.src = url; }
else if (el.dataset.step === '2') { el.dataset.step = '3'; el.src = proxied(url); }
};
const settingsPopup = (
<div style={{ width: 260 }}>
<Field label="后端地址">
<input
value={settings.baseUrl}
onChange={(e) => setSettings({ ...settings, baseUrl: e.target.value })}
onBlur={() => saveSettings(settings)}
/>
</Field>
<Field label="Token(可选)">
<input
value={settings.token}
onChange={(e) => setSettings({ ...settings, token: e.target.value })}
onBlur={() => saveSettings(settings)}
/>
</Field>
</div>
);
return (
<div className="page">
{/* ── 顶栏 ── */}
<div className="topbar">
{IN_PAGE && (
<button className="icon-btn" title="收起面板(Esc" onClick={closePanel}><CloseOutlined /></button>
)}
<div className="logo"></div>
<div>
<h1>电商套图工作台</h1>
<div className="sub">商品采集 · 套图生成 · 一键导出</div>
</div>
<div className="spacer" />
<Popover content={settingsPopup} title="服务端设置" trigger="click" placement="bottomRight">
<button className="icon-btn" title="服务端设置"><SettingOutlined /></button>
</Popover>
<button className="btn btn-primary btn-main" disabled={scanning} onClick={handleScan}>
{retrying ? '刷新重试中…' : scanning ? '采集中…' : '快速采集'}
</button>
</div>
{/* ── 目标平台切换(决定文案语言 + 图片比例)── */}
<div className="platform-bar">
<span className="platform-label">目标平台</span>
<div className="seg-group">
{PLATFORM_OPTIONS.map(p => (
<button
key={p.value}
className={`seg-btn ${platform === p.value ? 'on' : ''}`}
onClick={() => setPlatform(p.value)}
>{p.label}</button>
))}
</div>
<span className="platform-spec">
{PLATFORM_SPECS[platform].lang}文案 · {PLATFORM_SPECS[platform].ratio} 图片
</span>
</div>
{/* ── 采集区:左信息 / 右图片,两列等高 ── */}
<div className="two-col">
<Section
no="01"
title="商品信息"
extra={result
? `${PLATFORM_LABELS[result.platform] ?? result.platform} · ${SOURCE_LABELS[result.source] ?? result.source}`
: undefined}
>
{!result ? (
<div className="empty">点击右上角「快速采集」抓取当前商品页</div>
) : (
<>
<Field label="标题">
<input value={titleEdit} onChange={(e) => setTitleEdit(e.target.value)} />
</Field>
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<Field label="价格">
<input value={priceEdit} onChange={(e) => setPriceEdit(e.target.value)} />
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="品牌">
<input value={brand} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
</div>
<Field label="尺寸(长 ×× 高)">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<input placeholder="长" value={dimsEdit.l} onChange={(e) => setDimsEdit(d => ({ ...d, l: e.target.value }))} />
<span style={{ color: 'var(--text-2)' }}>×</span>
<input placeholder="宽" value={dimsEdit.w} onChange={(e) => setDimsEdit(d => ({ ...d, w: e.target.value }))} />
<span style={{ color: 'var(--text-2)' }}>×</span>
<input placeholder="高" value={dimsEdit.h} onChange={(e) => setDimsEdit(d => ({ ...d, h: e.target.value }))} />
</div>
</Field>
<Field label="重量">
<input placeholder="如 428g / 0.43kg" value={weightEdit} onChange={(e) => setWeightEdit(e.target.value)} />
</Field>
{(sales || shop) && (
<div style={{ display: 'flex', gap: 8 }}>
<div style={{ flex: 1 }}>
<Field label="销量">
<input value={sales} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="店铺">
<input value={shop} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
</div>
)}
{paramPairs.length > 0 && (
<div className="field">
<label style={{ cursor: 'pointer' }} onClick={() => setParamsOpen(v => !v)}>
规格 / 参数({paramPairs.length} 项){paramsOpen ? ' ▴' : ' ▾'}
</label>
{paramsOpen && (
<table className="kv-table">
<tbody>
{paramPairs.slice(0, 30).map((p, i) => (
<tr key={i}><td className="k">{p.key}</td><td>{p.value}</td></tr>
))}
</tbody>
</table>
)}
</div>
)}
<Field label="商品描述(用于生成图内文案)">
<textarea rows={10} value={descEdit} onChange={(e) => setDescEdit(e.target.value)} />
</Field>
{result.warnings.length > 0 && (
<div className="warn-box">{result.warnings.map((w, i) => <div key={i}>{w}</div>)}</div>
)}
</>
)}
</Section>
<Section
no="02"
title="采集图片"
className="section-images"
extra={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{(result || uploadedImages.length > 0) && (
<span>已选 {selectedKeys.size} / {allImages.filter(i => i.type !== 'video').length}</span>
)}
<button className="btn btn-sm" onClick={handleUpload} disabled={uploading}>
<UploadOutlined /> {uploading ? '上传中…' : '上传图片'}
</button>
</span>
}
>
<input
ref={fileRef}
type="file"
accept="image/*"
multiple
style={{ display: 'none' }}
onChange={onFilesChange}
/>
{allImages.length === 0 ? (
<div className="empty">采集后在此勾选图片,或点右上角「上传图片」手动补充</div>
) : (
<div className="img-groups">
{(['main', 'sku', 'detail', 'upload'] as const).map(g => groupImages(g).length > 0 && (
<div key={g} style={{ marginBottom: 10 }}>
<div className="group-head">
<span className="name">
{g === 'main' ? '主图' : g === 'sku' ? 'SKU图片' : g === 'detail' ? '详情图' : '手动上传'}
</span>
<span className="count">{groupImages(g).length}</span>
<span
className="mini-check"
onClick={() => {
const all = groupImages(g).every(i => selectedKeys.has(i.key));
toggleGroup(g, !all);
}}
>
{groupImages(g).every(i => selectedKeys.has(i.key)) ? '取消全选' : '全选'}
</span>
</div>
<div className="img-grid">
{groupImages(g).map(img => {
const on = selectedKeys.has(img.key);
return (
<div key={img.key} className={`img-cell ${on ? 'on' : ''}`}
title="点击放大预览,勾选圆点选择图片"
onClick={() => openPreview(collectedPreviewList, collectedPreviewList.indexOf(img.url))}>
<img
src={img.thumbUrl || img.url}
referrerPolicy="no-referrer"
data-step="1"
onError={(e) => onThumbError(e, img.url)}
/>
<span
className="tick"
onClick={(e) => { e.stopPropagation(); toggleKey(img.key); }}
></span>
{img.variantName && <span className="variant">{img.variantName}</span>}
</div>
);
})}
</div>
</div>
))}
</div>
)}
</Section>
</div>
{/* ── 出图方案(整行)── */}
<Section
no="03"
title={`出图方案(共 ${totalPlanned} 张)`}
extra={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 10 }}>
{planSource === 'ai' && <span className="ai-tag">AI 方案</span>}
<label className="auto-chk" title="勾选后,AI 规划完成将自动开始生成">
<input
type="checkbox"
checked={planThenGenerate}
onChange={(e) => setPlanThenGenerate(e.target.checked)}
/>
规划并生成
</label>
<button className="btn btn-ai btn-main" disabled={!result || planning} onClick={handlePlan}>
<ThunderboltOutlined /> {planning ? '规划中…' : 'AI 智能规划'}
</button>
</span>
}
>
<div className="plan-list">
{plan.map((p, idx) => (
<div key={idx} className={`plan-row ${p.count === 0 ? 'off' : ''}`}>
<div className="plan-main">
<span className="plan-title">{p.title}</span>
{p.variant_name && <span className="variant-chip">{p.variant_name}</span>}
{p.detail && <span className="plan-detail" title={p.detail}>{p.detail}</span>}
{p.prompt_hint && <span className="plan-detail plan-hint" title={p.prompt_hint}>🎯 {p.prompt_hint}</span>}
</div>
<Stepper value={p.count} onChange={(v) => setPlanCount(idx, v)} />
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '10px 0 4px' }}>
{planSource === 'ai' && (
<button
className="btn btn-sm"
onClick={() => { setPlan(DEFAULT_PLAN.map(p => ({ ...p }))); setPlanSource('default'); setPlanSummary(''); }}
>恢复默认方案</button>
)}
<span className="hint" style={{ flex: 1 }}>
{planSummary || '方案与张数由规划器根据商品信息自动决定,可手动微调,0 即不生成'}
</span>
</div>
<div className="divider" />
<div className="field">
<label>视觉风格</label>
<div className="pills">
{STYLE_SET_OPTIONS.map(s => (
<span
key={s.value}
className={`pill ${styleSet === s.value ? 'on' : ''}`}
onClick={() => setStyleSet(s.value)}
>{s.label}</span>
))}
</div>
</div>
<div className="field">
<label>
风格提示词(可编辑,直接决定生成画面的风格)
{stylePrompts[styleSet] !== undefined && (
<span
className="mini-check"
style={{ marginLeft: 8, fontWeight: 400 }}
onClick={() => setStylePrompts(p => {
const n = { ...p };
delete n[styleSet];
return n;
})}
>恢复默认</span>
)}
</label>
<textarea
rows={4}
value={currentStylePrompt}
onChange={(e) => setStylePrompts(p => ({ ...p, [styleSet]: e.target.value }))}
/>
</div>
<div className="field">
<label>生图要求(优先级最高,强制要求,会覆盖其他设定)</label>
<textarea
rows={3}
value={requirements}
onChange={(e) => setRequirements(e.target.value)}
placeholder="选填,例如:必须保留商品正面品牌标识;背景必须为纯黑色;不得添加任何文字水印"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
{generating && (
<div style={{ flex: 6 }}>
{/* 进度条与右侧弹性占位 6:1,长度 = 剩余空间的 6/7(两次加长后的累积比例) */}
<Progress
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
size={['100%', 10]} status="active" format={() => `${doneCount}/${suiteTotal}`}
/>
</div>
)}
<span style={{ flex: 1 }} />
<Select
value={model}
onChange={setModel}
style={{ width: 260 }}
popupMatchSelectWidth={false}
disabled={generating}
>
{IMAGE_MODEL_OPTIONS.map(m => (
<Select.Option key={m.value} value={m.value} label={m.label}>
<div className="model-opt">
<div className="model-opt-name">{m.label}</div>
<div className="model-opt-desc">{m.desc}</div>
</div>
</Select.Option>
))}
</Select>
<button
className="btn btn-primary btn-main"
style={{ alignSelf: 'stretch' }}
disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生图(${totalPlanned} 张)`}
</button>
</div>
</Section>
{/* ── 生成结果(整行)── */}
<Section
no="04"
title="生成结果"
extra={
suite && ['done', 'partial'].includes(suite.status) && (
<button className="btn btn-sm" onClick={handleExport}>
<DownloadOutlined /> 导出 ZIP
</button>
)
}
>
{!suite ? (
<div className="empty">生成后在此查看与导出</div>
) : (
<>
<div style={{ marginBottom: 8 }} className="hint">
状态:<b style={{ color: suite.status === 'done' ? 'var(--green)' : suite.status === 'failed' ? 'var(--red)' : 'var(--primary)' }}>
{suite.status === 'running' ? '生成中' : suite.status === 'done' ? '完成' : suite.status === 'partial' ? '部分失败' : suite.status === 'pending' ? '排队中' : '失败'}
</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 ?? '自定义'}
</div>
<div className="result-grid">
{suite.images.map(img => (
<div
key={img.type_id + img.name}
className={`result-cell ${img.status !== 'ok' ? 'fail' : ''}`}
title={img.error || img.name}
style={{ cursor: img.status === 'ok' ? 'zoom-in' : 'default' }}
onClick={() => img.status === 'ok' && openPreview(resultPreviewList, resultPreviewList.indexOf(img.url))}
>
{img.status === 'ok' ? (
<img src={img.url} referrerPolicy="no-referrer" />
) : (
<div style={{ aspectRatio: suite.ratio === '3:4' ? '3/4' : '1', background: 'var(--card-soft)' }} />
)}
{img.status !== 'ok' && <span className="fail-tag">{img.status === 'failed' ? '✗' : '…'}</span>}
<div className="cap" title={img.error || img.name}>
{img.status === 'failed' && img.error ? img.error : img.name}
</div>
</div>
))}
</div>
{suite.error && <div className="warn-box">{suite.error}</div>}
</>
)}
</Section>
{/* ── 图片放大预览(画廊:← → 切换)── */}
{preview && (
<div className="lightbox" onClick={() => setPreview(null)}>
<img
src={preview.list[preview.index]}
referrerPolicy="no-referrer"
onClick={(e) => e.stopPropagation()}
/>
{preview.list.length > 1 && (
<>
<button
className="lightbox-nav prev"
onClick={(e) => { e.stopPropagation(); previewPrev(); }}
title="上一张(←)"
></button>
<button
className="lightbox-nav next"
onClick={(e) => { e.stopPropagation(); previewNext(); }}
title="下一张(→)"
></button>
<div className="lightbox-counter" onClick={(e) => e.stopPropagation()}>
{preview.index + 1} / {preview.list.length}
</div>
</>
)}
<span className="lightbox-tip">点击任意处关闭 · 切换</span>
</div>
)}
</div>
);
};
const Root: React.FC = () => (
<ConfigProvider
theme={{
token: {
colorPrimary: '#8b5cf6',
colorLink: '#8b5cf6',
borderRadius: 8,
fontFamily: "Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif",
},
}}
>
<AntApp>
<App />
</AntApp>
</ConfigProvider>
);
createRoot(document.getElementById('root')!).render(<Root />);