feat: 插件开发 ozon 端主体完成

This commit is contained in:
Joey
2026-08-16 17:32:43 +08:00
parent 1591d5e35a
commit b57933e983
32 changed files with 1960 additions and 512 deletions
+315 -79
View File
@@ -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>
+70 -6
View File
@@ -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;