835 lines
36 KiB
TypeScript
835 lines
36 KiB
TypeScript
/**
|
||
* 电商套图工作台 - 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 } from '@ant-design/icons';
|
||
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
|
||
import {
|
||
buildGeneratePayload, suiteZipUrl,
|
||
DEFAULT_PLAN, IMAGE_MODEL_OPTIONS, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
|
||
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;
|
||
|
||
/** 在指定 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);
|
||
|
||
// 服务端
|
||
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);
|
||
/** 生图模型(通义 DashScope,默认 wan2.7-image-pro,与后端 .env 默认一致) */
|
||
const [model, setModel] = useState<string>('wan2.7-image-pro');
|
||
/** 用户改写的风格提示词(按风格 id 存,切风格不丢) */
|
||
const [stylePrompts, setStylePrompts] = useState<Record<number, string>>({});
|
||
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]);
|
||
|
||
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();
|
||
pollRef.current = setInterval(async () => {
|
||
try {
|
||
const s = await send<SuiteInfo>('getSuite', { baseUrl: settings.baseUrl, token: settings.token, suiteId });
|
||
setSuite(s);
|
||
if (['done', 'partial', 'failed'].includes(s.status)) {
|
||
stopPolling();
|
||
setGenerating(false);
|
||
if (s.status === 'partial') modal.warning({ title: '部分生成失败', content: '可重试或更换风格重新生成', okText: '知道了' });
|
||
if (s.status === 'failed') modal.error({ title: '生成失败', content: s.error || '未知错误', okText: '知道了' });
|
||
}
|
||
} catch (e) {
|
||
stopPolling();
|
||
setGenerating(false);
|
||
}
|
||
}, 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(
|
||
result, selectedKeys, editedTexts(),
|
||
{ style_set: styleSet, style_prompt: currentStylePrompt, 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,
|
||
},
|
||
});
|
||
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;
|
||
const suiteTotal = suite?.images.length ?? 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: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`,
|
||
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);
|
||
};
|
||
|
||
const groupImages = (groupKey: string): ImageMaterial[] =>
|
||
result?.images.filter(i => i.groupKey === groupKey) ?? [];
|
||
|
||
/** 预览用的全量图序列(主图→SKU→详情,与展示顺序一致) */
|
||
const collectedPreviewList: string[] = result
|
||
? (['main', 'sku', 'detail'] 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));
|
||
};
|
||
|
||
/** 源站图防盗链时的兜底:走服务端图片代理 */
|
||
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">
|
||
<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={result ? `已选 ${selectedKeys.size} / ${result.images.filter(i => i.type !== 'video').length}` : undefined}
|
||
>
|
||
{!result ? (
|
||
<div className="empty">采集后在此勾选图片</div>
|
||
) : (
|
||
<div className="img-groups">
|
||
{['main', 'sku', 'detail'].map(g => groupImages(g).length > 0 && (
|
||
<div key={g} style={{ marginBottom: 10 }}>
|
||
<div className="group-head">
|
||
<span className="name">
|
||
{g === 'main' ? '主图' : g === 'sku' ? 'SKU图片' : '详情图'}
|
||
</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>}
|
||
</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 style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
|
||
{generating && (
|
||
<div style={{ flex: 1 }}>
|
||
<Progress
|
||
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
|
||
size="small" 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"
|
||
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">{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 />);
|