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

599 lines
24 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 } from 'antd';
import { SettingOutlined, DownloadOutlined, ThunderboltOutlined } from '@ant-design/icons';
import type { ScanResult, ImageMaterial } from '../../src/collector/scan';
import {
buildGeneratePayload, suiteZipUrl,
DEFAULT_PLAN, 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: '淘宝/天猫',
};
/** 在当前活动 tab 执行采集(content script 已把入口挂到 window */
async function scanActiveTab(): Promise<ScanResult> {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) throw new Error('未找到活动标签页');
const [res] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: () => (window as any).__SuiteCollector?.scan?.() ?? null,
});
const r = (res?.result ?? null) as ScanResult | null;
if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试');
return r;
}
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 }> =
({ no, title, extra, children }) => (
<div className="section">
<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 [result, setResult] = useState<ScanResult | null>(null);
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [titleEdit, setTitleEdit] = 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'>('cn');
const [styleSet, setStyleSet] = useState(1);
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 [suite, setSuite] = useState<SuiteInfo | null>(null);
const [generating, setGenerating] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
// 图片放大预览
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
loadSettings().then(setSettings);
return () => { if (pollRef.current) clearInterval(pollRef.current); };
}, []);
const price = result?.texts.find(t => t.kind === 'price')?.content ?? '';
const brand = result?.texts.find(t => t.kind === 'brand')?.content ?? '';
const paramPairs = result?.texts.find(t => t.kind === 'params')?.pairs ?? [];
const handleScan = async () => {
setScanning(true);
try {
const r = await scanActiveTab();
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 ?? '');
} catch (e) {
modal.error({ title: '采集失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
setScanning(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);
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 (orig('price')?.content) texts.push({ kind: 'price', content: orig('price')!.content });
if (orig('brand')?.content) texts.push({ kind: 'brand', content: orig('brand')!.content });
if ((orig('params')?.pairs ?? []).length) texts.push({ kind: 'params', content: '', pairs: orig('params')!.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;
};
/** AI 智能规划出图方案 */
const handlePlan = async () => {
if (!result) return modal.warning({ title: '请先采集商品页' });
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,
},
});
setPlan(data.items);
setPlanSource('ai');
setPlanSummary(data.summary);
const total = data.items.reduce((s, i) => s + i.count, 0);
modal.info({
title: 'AI 方案已生成',
content: `${data.summary}(共 ${total} 张)。可逐项调整数量,0 即不生成。`,
okText: '好的',
});
} catch (e) {
modal.error({ title: '规划失败', content: e instanceof Error ? e.message : String(e), okText: '知道了' });
} finally {
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 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}),风格「${STYLE_SET_OPTIONS.find(s => s.value === styleSet)?.label}」,共 ${totalPlanned} 张、参考图 ${selectedKeys.size} 张。生成需要几分钟,可在下方查看进度。`,
okText: '开始生成', cancelText: '取消',
onOk: async () => {
setGenerating(true);
setSuite(null);
try {
const payload = buildGeneratePayload(
result, selectedKeys,
{ title: titleEdit, desc: descEdit },
{ style_set: styleSet, plan: plan.filter(p => p.count > 0), platform },
);
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: '知道了' });
}
},
});
};
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) ?? [];
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" disabled={scanning} onClick={handleScan}>
{scanning ? '采集中…' : '快速采集'}
</button>
</div>
{/* ── 目标平台切换(决定文案语言 + 图片比例)── */}
<div className="platform-bar">
<span className="platform-label">目标平台</span>
<div className="pills">
{PLATFORM_OPTIONS.map(p => (
<span
key={p.value}
className={`pill ${platform === p.value ? 'on' : ''}`}
onClick={() => setPlatform(p.value)}
>{p.label}</span>
))}
</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={price} readOnly style={{ color: 'var(--text-2)' }} />
</Field>
</div>
<div style={{ flex: 1 }}>
<Field label="品牌">
<input value={brand} 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={6} 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="采集图片"
extra={result ? `已选 ${selectedKeys.size} / ${result.images.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={() => setPreviewUrl(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="出图方案"
extra={
<span>
{planSource === 'ai' ? <span className="ai-tag">AI 方案</span> : '默认方案'}
{' '} <b style={{ color: 'var(--primary)' }}>{totalPlanned}</b>
</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' }}>
<button className="btn" disabled={!result || planning} onClick={handlePlan}>
<ThunderboltOutlined /> {planning ? '规划中…' : 'AI 智能规划'}
</button>
{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 style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 4 }}>
<button
className="btn btn-primary" disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
onClick={handleGenerate}
>
{generating ? '生成中…' : `一键生成(${totalPlanned} 张)`}
</button>
{generating && (
<div style={{ flex: 1 }}>
<Progress
percent={suiteTotal ? Math.round(doneCount / suiteTotal * 100) : 0}
size="small" status="active" format={() => `${doneCount}/${suiteTotal}`}
/>
</div>
)}
</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' && setPreviewUrl(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>
{/* ── 图片放大预览 ── */}
{previewUrl && (
<div className="lightbox" onClick={() => setPreviewUrl(null)}>
<img src={previewUrl} referrerPolicy="no-referrer" onClick={(e) => e.stopPropagation()} />
<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 />);