feat: 插件开发 ozon 端主体完成
This commit is contained in:
@@ -10,12 +10,12 @@
|
||||
*/
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App as AntApp, ConfigProvider, Popover, Progress } from 'antd';
|
||||
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, PLATFORM_OPTIONS, PLATFORM_SPECS, STYLE_SET_OPTIONS,
|
||||
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';
|
||||
@@ -37,17 +37,31 @@ const PLATFORM_LABELS: Record<string, string> = {
|
||||
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 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: tab.id },
|
||||
target: { tabId },
|
||||
func: () => (window as any).__SuiteCollector?.scan?.() ?? null,
|
||||
});
|
||||
const r = (res?.result ?? null) as ScanResult | null;
|
||||
if (!r) throw new Error('采集失败:页面不支持或内容脚本未就绪,请刷新页面后重试');
|
||||
return r;
|
||||
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> {
|
||||
@@ -62,9 +76,11 @@ function send<T>(action: string, payload: Record<string, unknown>): Promise<T> {
|
||||
|
||||
// ── 小组件 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const Section: React.FC<{ no: string; title: string; extra?: React.ReactNode; children: React.ReactNode }> =
|
||||
({ no, title, extra, children }) => (
|
||||
<div className="section">
|
||||
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>
|
||||
@@ -95,9 +111,13 @@ const App: React.FC = () => {
|
||||
|
||||
// 采集
|
||||
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);
|
||||
|
||||
@@ -105,34 +125,107 @@ const App: React.FC = () => {
|
||||
const [settings, setSettings] = useState<BackendSettings>({ baseUrl: 'http://127.0.0.1:3300', token: '' });
|
||||
|
||||
// 出图方案
|
||||
const [platform, setPlatform] = useState<'ozon' | 'wb' | 'cn'>('cn');
|
||||
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 [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
// 图片放大预览(画廊:可左右切换)
|
||||
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); };
|
||||
}, []);
|
||||
|
||||
const price = result?.texts.find(t => t.kind === 'price')?.content ?? '';
|
||||
// 预览态的键盘导航:← → 切换,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 = async () => {
|
||||
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 r = await scanActiveTab();
|
||||
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);
|
||||
@@ -141,10 +234,19 @@ const App: React.FC = () => {
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -169,25 +271,66 @@ const App: React.FC = () => {
|
||||
}, 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 (orig('price')?.content) texts.push({ kind: 'price', content: orig('price')!.content });
|
||||
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('params')?.pairs ?? []).length) texts.push({ kind: 'params', content: '', pairs: orig('params')!.pairs });
|
||||
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;
|
||||
};
|
||||
|
||||
/** AI 智能规划出图方案 */
|
||||
/** 提交生成任务(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(
|
||||
@@ -202,25 +345,34 @@ const App: React.FC = () => {
|
||||
platform,
|
||||
},
|
||||
});
|
||||
if (seq !== planSeqRef.current) return; // 规划已被重新采集重置,丢弃过期响应
|
||||
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: '好的',
|
||||
});
|
||||
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 {
|
||||
setPlanning(false);
|
||||
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;
|
||||
@@ -229,26 +381,9 @@ const App: React.FC = () => {
|
||||
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} 张。生成需要几分钟,可在下方查看进度。`,
|
||||
content: `目标平台「${spec.label}」(${spec.lang}文案 · ${spec.ratio}),模型「${model}」,风格「${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: '知道了' });
|
||||
}
|
||||
},
|
||||
onOk: () => startGenerate(),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -266,6 +401,13 @@ const App: React.FC = () => {
|
||||
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));
|
||||
@@ -319,21 +461,21 @@ const App: React.FC = () => {
|
||||
<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 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="pills">
|
||||
<div className="seg-group">
|
||||
{PLATFORM_OPTIONS.map(p => (
|
||||
<span
|
||||
<button
|
||||
key={p.value}
|
||||
className={`pill ${platform === p.value ? 'on' : ''}`}
|
||||
className={`seg-btn ${platform === p.value ? 'on' : ''}`}
|
||||
onClick={() => setPlatform(p.value)}
|
||||
>{p.label}</span>
|
||||
>{p.label}</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="platform-spec">
|
||||
@@ -360,7 +502,7 @@ const App: React.FC = () => {
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Field label="价格">
|
||||
<input value={price} readOnly style={{ color: 'var(--text-2)' }} />
|
||||
<input value={priceEdit} onChange={(e) => setPriceEdit(e.target.value)} />
|
||||
</Field>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
@@ -369,6 +511,32 @@ const App: React.FC = () => {
|
||||
</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)}>
|
||||
@@ -386,7 +554,7 @@ const App: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
<Field label="商品描述(用于生成图内文案)">
|
||||
<textarea rows={6} value={descEdit} onChange={(e) => setDescEdit(e.target.value)} />
|
||||
<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>
|
||||
@@ -398,7 +566,8 @@ const App: React.FC = () => {
|
||||
<Section
|
||||
no="02"
|
||||
title="采集图片"
|
||||
extra={result ? `已选 ${selectedKeys.size} / ${result.images.length}` : undefined}
|
||||
className="section-images"
|
||||
extra={result ? `已选 ${selectedKeys.size} / ${result.images.filter(i => i.type !== 'video').length}` : undefined}
|
||||
>
|
||||
{!result ? (
|
||||
<div className="empty">采集后在此勾选图片</div>
|
||||
@@ -427,7 +596,7 @@ const App: React.FC = () => {
|
||||
return (
|
||||
<div key={img.key} className={`img-cell ${on ? 'on' : ''}`}
|
||||
title="点击放大预览,勾选圆点选择图片"
|
||||
onClick={() => setPreviewUrl(img.url)}>
|
||||
onClick={() => openPreview(collectedPreviewList, collectedPreviewList.indexOf(img.url))}>
|
||||
<img
|
||||
src={img.thumbUrl || img.url}
|
||||
referrerPolicy="no-referrer"
|
||||
@@ -453,11 +622,21 @@ const App: React.FC = () => {
|
||||
{/* ── 出图方案(整行)── */}
|
||||
<Section
|
||||
no="03"
|
||||
title="出图方案"
|
||||
title={`出图方案(共 ${totalPlanned} 张)`}
|
||||
extra={
|
||||
<span>
|
||||
{planSource === 'ai' ? <span className="ai-tag">AI 方案</span> : '默认方案'}
|
||||
{' '}共 <b style={{ color: 'var(--primary)' }}>{totalPlanned}</b> 张
|
||||
<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>
|
||||
}
|
||||
>
|
||||
@@ -473,10 +652,7 @@ const App: React.FC = () => {
|
||||
</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>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, margin: '10px 0 4px' }}>
|
||||
{planSource === 'ai' && (
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
@@ -501,13 +677,28 @@ const App: React.FC = () => {
|
||||
))}
|
||||
</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 }}>
|
||||
<button
|
||||
className="btn btn-primary" disabled={!result || generating || selectedKeys.size === 0 || totalPlanned === 0}
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{generating ? '生成中…' : `一键生成(${totalPlanned} 张)`}
|
||||
</button>
|
||||
{generating && (
|
||||
<div style={{ flex: 1 }}>
|
||||
<Progress
|
||||
@@ -516,6 +707,30 @@ const App: React.FC = () => {
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -550,7 +765,7 @@ const App: React.FC = () => {
|
||||
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)}
|
||||
onClick={() => img.status === 'ok' && openPreview(resultPreviewList, resultPreviewList.indexOf(img.url))}
|
||||
>
|
||||
{img.status === 'ok' ? (
|
||||
<img src={img.url} referrerPolicy="no-referrer" />
|
||||
@@ -567,11 +782,32 @@ const App: React.FC = () => {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* ── 图片放大预览 ── */}
|
||||
{previewUrl && (
|
||||
<div className="lightbox" onClick={() => setPreviewUrl(null)}>
|
||||
<img src={previewUrl} referrerPolicy="no-referrer" onClick={(e) => e.stopPropagation()} />
|
||||
<span className="lightbox-tip">点击任意处关闭</span>
|
||||
{/* ── 图片放大预览(画廊:← → 切换)── */}
|
||||
{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>
|
||||
|
||||
@@ -41,7 +41,14 @@
|
||||
.two-col { display: flex; gap: 14px; align-items: stretch; margin-bottom: 14px; }
|
||||
.two-col .section { flex: 1; min-width: 0; margin-bottom: 0; display: flex; flex-direction: column; }
|
||||
.two-col .section .section-head { flex-shrink: 0; }
|
||||
.img-groups { flex: 1; overflow-y: auto; max-height: 560px; }
|
||||
/* 图片列表占满 section 除标题外的剩余高度;min-height:0 是 flex 子项内滚动的关键 */
|
||||
.img-groups { flex: 1 1 auto; min-height: 0; overflow-y: auto; max-height: 78vh; }
|
||||
/* 采集图片区:section 自身去掉左右 padding,标题行自持 padding;
|
||||
图片区左侧对齐标题,右侧只留窄缝给滚动条(滚动条贴卡片内缘,图片与滚动条之间有小间距) */
|
||||
.section-images { padding: 14px 0 !important; }
|
||||
.section-images .section-head { padding: 0 16px; }
|
||||
.section-images .img-groups { padding: 2px 8px 0 16px; }
|
||||
.section-images .empty { margin: 0 16px; }
|
||||
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
|
||||
|
||||
/* ── 顶部 ── */
|
||||
@@ -60,7 +67,7 @@
|
||||
.topbar .sub { font-size: 12px; color: var(--text-2); margin-top: 1px; }
|
||||
.topbar .spacer { flex: 1; }
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||
padding: 8px 16px; border-radius: 8px; border: 1px solid var(--border-strong);
|
||||
font-size: 14px; cursor: pointer; user-select: none;
|
||||
background: #fff; color: var(--text);
|
||||
@@ -72,6 +79,25 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
|
||||
/* AI 智能规划:深靛紫渐变(智慧/深度感) */
|
||||
.btn-ai {
|
||||
background: linear-gradient(135deg, #4338ca 0%, #6d28d9 100%);
|
||||
border: none; color: #fff; font-weight: 600;
|
||||
box-shadow: 0 2px 10px rgba(88, 60, 210, 0.35);
|
||||
}
|
||||
.btn-ai:hover:not([disabled]) {
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
color: #fff; box-shadow: 0 3px 14px rgba(88, 60, 210, 0.45);
|
||||
}
|
||||
/* 三个主操作按钮统一宽度 */
|
||||
.btn-main { width: 160px; }
|
||||
/* 「规划并生成」复选框 */
|
||||
.auto-chk {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-size: 12.5px; color: var(--text-2); cursor: pointer; user-select: none;
|
||||
}
|
||||
.auto-chk input { accent-color: var(--primary); width: 14px; height: 14px; cursor: pointer; }
|
||||
.auto-chk:hover { color: var(--text); }
|
||||
.btn[disabled] { opacity: .5; cursor: not-allowed; }
|
||||
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
|
||||
.icon-btn {
|
||||
@@ -120,9 +146,10 @@
|
||||
/* ── 药丸选择 ── */
|
||||
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.pill {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
padding: 5px 13px; border-radius: 999px; border: 1px solid var(--border-strong);
|
||||
background: #fff; font-size: 13px; cursor: pointer; color: var(--text-2);
|
||||
user-select: none; transition: all .15s; line-height: 1.6;
|
||||
user-select: none; transition: all .15s; line-height: 1.4;
|
||||
}
|
||||
.pill:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.pill.on {
|
||||
@@ -138,7 +165,7 @@
|
||||
}
|
||||
.group-head .mini-check { margin-left: auto; font-size: 12px; color: var(--primary); cursor: pointer; user-select: none; }
|
||||
.group-head .mini-check:hover { text-decoration: underline; }
|
||||
.img-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 7px; }
|
||||
.img-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
||||
.img-cell {
|
||||
position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden;
|
||||
border: 2px solid transparent; cursor: zoom-in; background: var(--card-soft);
|
||||
@@ -181,10 +208,26 @@
|
||||
}
|
||||
.platform-label { font-size: 13px; font-weight: 700; }
|
||||
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
|
||||
|
||||
/* 平台切换:Button.Group 形式,选中态用低饱和灰绿(不抢主题色) */
|
||||
.seg-group { display: inline-flex; }
|
||||
.seg-btn {
|
||||
padding: 7px 20px; font-size: 13.5px; font-family: inherit;
|
||||
min-width: 120px; text-align: center; /* 选中加粗会让文字变宽,固定宽度消除跳动 */
|
||||
background: #fff; border: 1px solid var(--border-strong); border-left-width: 0;
|
||||
color: var(--text-2); cursor: pointer; user-select: none; transition: all .15s;
|
||||
}
|
||||
.seg-group .seg-btn:first-child { border-left-width: 1px; border-radius: 8px 0 0 8px; }
|
||||
.seg-group .seg-btn:last-child { border-radius: 0 8px 8px 0; }
|
||||
.seg-btn:hover { color: var(--text); background: var(--card-soft); }
|
||||
.seg-btn.on {
|
||||
background: #eef0eb; border-color: #c9cec6; color: #3f453c; font-weight: 700;
|
||||
}
|
||||
.seg-group .seg-btn.on + .seg-btn { border-left-color: #c9cec6; }
|
||||
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 min-width 消除抖动 */
|
||||
.platform-bar .pill { min-width: 108px; text-align: center; }
|
||||
|
||||
/* ── 图片放大预览 ── */
|
||||
/* ── 图片放大预览(画廊)── */
|
||||
.lightbox {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0, 0, 0, 0.82);
|
||||
@@ -192,9 +235,26 @@
|
||||
flex-direction: column; gap: 12px; cursor: zoom-out;
|
||||
}
|
||||
.lightbox img {
|
||||
max-width: 92%; max-height: 86%;
|
||||
max-width: 88%; max-height: 82%;
|
||||
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.lightbox-nav {
|
||||
position: absolute; top: 50%; transform: translateY(-50%);
|
||||
width: 40px; height: 64px; border: none; border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.12); color: #fff;
|
||||
font-size: 30px; line-height: 1; cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
transition: background .15s; user-select: none;
|
||||
}
|
||||
.lightbox-nav:hover { background: rgba(255, 255, 255, 0.28); }
|
||||
.lightbox-nav.prev { left: 14px; }
|
||||
.lightbox-nav.next { right: 14px; }
|
||||
.lightbox-counter {
|
||||
position: absolute; top: 14px; right: 16px;
|
||||
background: rgba(0, 0, 0, 0.5); color: #fff;
|
||||
font-size: 13px; padding: 3px 10px; border-radius: 999px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
|
||||
|
||||
/* ── 出图方案 ── */
|
||||
@@ -233,6 +293,10 @@
|
||||
}
|
||||
|
||||
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
|
||||
|
||||
/* ── 模型下拉选项 ── */
|
||||
.model-opt-name { font-size: 13.5px; font-weight: 600; color: var(--text); }
|
||||
.model-opt-desc { font-size: 12px; color: var(--text-2); margin-top: 2px; }
|
||||
.ok-chip {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
|
||||
|
||||
Reference in New Issue
Block a user