feat: 初始化项目,并且接近完成 ozon 部分

This commit is contained in:
Joey
2026-08-15 22:19:27 +08:00
commit 1591d5e35a
46 changed files with 9827 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
import { generateSuite, getSuite, planSuite } from '../src/api/client';
// Background Service Worker —— 唯一出网口(生成 / 规划 / 轮询任务,绕 CORS)
export default defineBackground(() => {
console.log('[电商套图工作台] background started');
// 点击扩展图标 → 打开 Side Panel
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true });
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg?.action === 'generateSuite') {
generateSuite(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (msg?.action === 'planSuite') {
planSuite(msg.baseUrl, msg.token, msg.payload)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
if (msg?.action === 'getSuite') {
getSuite(msg.baseUrl, msg.token, msg.suiteId)
.then((data) => sendResponse({ ok: true, data }))
.catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) }));
return true;
}
return false;
});
});
+26
View File
@@ -0,0 +1,26 @@
// Content Script —— 注入四个平台的商品页,暴露采集入口
import { scanCurrentPage } from '../../src/collector/scan';
export default defineContentScript({
matches: [
// Ozon
'https://*.ozon.ru/*',
'https://*.ozon.kz/*',
'https://*.ozon.by/*',
// 1688
'https://detail.1688.com/*',
// 淘宝 / 天猫
'https://item.taobao.com/*',
'https://detail.tmall.com/*',
],
main() {
console.log('[电商套图工作台] Content script loaded');
// 暴露采集入口到全局(供 side panel 调用 / console 调试)
(window as any).__SuiteCollector = {
scan: scanCurrentPage,
};
console.log('[电商套图工作台] 就绪。Console 可测: await window.__SuiteCollector.scan()');
},
});
+598
View File
@@ -0,0 +1,598 @@
/**
* 电商套图工作台 - 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 />);
+255
View File
@@ -0,0 +1,255 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>电商套图工作台</title>
<style>
:root {
--bg: #f5f5f5; /* 页面背景(中性灰) */
--card: #ffffff;
--card-soft: #fafafa;
--border: #f0f0f0;
--border-strong: #e0e0e0;
--primary: #8b5cf6; /* 紫(ozon-seller-kit v2 主题色) */
--primary-hover: #7c3aed;
--primary-ring: rgba(139, 92, 246, 0.12);
--green: #52c41a;
--red: #ff4d4f;
--warn-bg: #fffbe6;
--warn-border: #ffe58f;
--warn-text: #8c6d1f;
--text: #262626;
--text-2: #8c8c8c;
}
html, body, #root {
min-width: 860px;
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
font-size: 14px;
}
* { box-sizing: border-box; }
/* ── 页面骨架 ── */
.page { padding: 16px 18px 22px; }
/* 采集区两列等高:左右卡片拉伸到同一高度 */
.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; }
.divider { border-top: 1px solid var(--border); margin: 12px 0; }
/* ── 顶部 ── */
.topbar {
display: flex; align-items: center; gap: 11px;
padding-bottom: 14px; margin-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.logo {
width: 38px; height: 38px; border-radius: 9px;
background: var(--primary); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 19px; font-weight: 700;
}
.topbar h1 { font-size: 17px; margin: 0; font-weight: 700; }
.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;
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);
transition: all .15s;
}
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn-primary {
background: var(--primary); border-color: var(--primary); color: #fff;
font-weight: 600;
}
.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); color: #fff; }
.btn[disabled] { opacity: .5; cursor: not-allowed; }
.btn-sm { padding: 5px 11px; font-size: 12.5px; }
.icon-btn {
width: 34px; height: 34px; border-radius: 8px; border: 1px solid var(--border-strong);
background: #fff; cursor: pointer; color: var(--text-2);
display: inline-flex; align-items: center; justify-content: center;
}
.icon-btn:hover { color: var(--primary); border-color: var(--primary); }
/* ── 编号步骤卡片 ── */
.section {
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 14px 16px; margin-bottom: 14px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
}
.section-head { display: flex; align-items: baseline; gap: 9px; margin-bottom: 12px; }
.section-no {
font-size: 20px; font-weight: 800; color: var(--primary);
font-variant-numeric: tabular-nums; line-height: 1;
}
.section-title { font-size: 15px; font-weight: 700; }
.section-extra { margin-left: auto; font-size: 12px; color: var(--text-2); }
/* ── 字段 ── */
.field { margin-bottom: 10px; }
.field label { display: block; font-size: 12.5px; color: var(--text-2); margin-bottom: 4px; }
.field input, .field textarea {
width: 100%; padding: 8px 11px; border: 1px solid var(--border-strong);
border-radius: 6px; font-size: 14px; font-family: inherit; line-height: 1.5;
background: var(--card-soft); color: var(--text); outline: none;
transition: border-color .15s, box-shadow .15s;
}
.field input:focus, .field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-ring);
background: #fff;
}
.kv-table {
width: 100%; border-collapse: collapse; font-size: 13px;
background: var(--card-soft); border-radius: 6px; overflow: hidden;
}
.kv-table td { padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.kv-table tr:last-child td { border-bottom: none; }
.kv-table td.k { color: var(--text-2); white-space: nowrap; width: 1%; padding-right: 16px; }
/* ── 药丸选择 ── */
.pills { display: flex; flex-wrap: wrap; gap: 7px; }
.pill {
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;
}
.pill:hover { border-color: var(--primary); color: var(--primary); }
.pill.on {
background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600;
}
/* ── 图片网格 ── */
.group-head { display: flex; align-items: center; gap: 8px; margin: 4px 0 9px; }
.group-head .name { font-size: 13px; font-weight: 600; color: var(--text-2); }
.group-head .count {
font-size: 12px; color: var(--text-2); background: var(--card-soft);
border: 1px solid var(--border); border-radius: 999px; padding: 0 8px;
}
.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-cell {
position: relative; aspect-ratio: 1; border-radius: 6px; overflow: hidden;
border: 2px solid transparent; cursor: zoom-in; background: var(--card-soft);
}
.img-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.img-cell.on { border-color: var(--primary); }
.img-cell .tick {
position: absolute; top: 5px; left: 5px; width: 18px; height: 18px;
border-radius: 50%; border: 1.5px solid #fff;
background: rgba(255, 255, 255, 0.55); display: flex; align-items: center; justify-content: center;
color: #fff; font-size: 11px; transition: all .15s; cursor: pointer;
}
.img-cell.on .tick { background: var(--primary); border-color: var(--primary); }
.img-cell .variant {
position: absolute; bottom: 0; left: 0; right: 0;
background: rgba(0, 0, 0, 0.55); color: #fff; font-size: 11px;
padding: 1px 5px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ── 生成结果(整行,6 列)── */
.result-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: 8px; }
.result-cell { position: relative; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
.result-cell img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; }
.result-cell .cap {
font-size: 11.5px; text-align: center; padding: 3px 0;
background: var(--card-soft); color: var(--text-2);
border-top: 1px solid var(--border); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.result-cell.fail { opacity: .55; }
.result-cell .fail-tag {
position: absolute; top: 5px; right: 5px; font-size: 11px;
background: var(--red); color: #fff; border-radius: 4px; padding: 0 5px;
}
/* ── 目标平台切换条 ── */
.platform-bar {
display: flex; align-items: center; gap: 10px;
background: var(--card); border: 1px solid var(--border);
border-radius: 8px; padding: 10px 16px; margin-bottom: 14px;
}
.platform-label { font-size: 13px; font-weight: 700; }
.platform-spec { margin-left: auto; font-size: 12.5px; color: var(--text-2); }
/* 三个平台药丸等宽:选中态加粗会让文字变宽,用固定 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);
display: flex; align-items: center; justify-content: center;
flex-direction: column; gap: 12px; cursor: zoom-out;
}
.lightbox img {
max-width: 92%; max-height: 86%;
border-radius: 8px; box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.lightbox-tip { color: rgba(255, 255, 255, 0.75); font-size: 12.5px; }
/* ── 出图方案 ── */
.plan-list { display: flex; flex-direction: column; gap: 4px; }
.plan-row {
display: flex; align-items: center; gap: 10px;
padding: 7px 10px; border: 1px solid var(--border); border-radius: 6px;
background: var(--card-soft);
}
.plan-row.off { opacity: .45; }
.plan-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 8px; }
.plan-title { font-size: 13.5px; font-weight: 600; white-space: nowrap; }
.variant-chip {
flex-shrink: 0; font-size: 11.5px; padding: 0 8px; line-height: 1.8;
border-radius: 999px; background: #f3efff; border: 1px solid #ddd3fa; color: #6d28d9;
}
.plan-detail {
font-size: 12px; color: var(--text-2);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stepper { display: inline-flex; align-items: center; gap: 0; flex-shrink: 0; }
.step-btn {
width: 24px; height: 24px; border: 1px solid var(--border-strong); background: #fff;
border-radius: 5px; cursor: pointer; font-size: 14px; line-height: 1; color: var(--text);
display: inline-flex; align-items: center; justify-content: center;
}
.step-btn:hover:not([disabled]) { border-color: var(--primary); color: var(--primary); }
.step-btn[disabled] { opacity: .35; cursor: not-allowed; }
.step-num {
min-width: 28px; text-align: center; font-size: 13.5px; font-weight: 600;
font-variant-numeric: tabular-nums;
}
.ai-tag {
background: var(--primary); color: #fff; font-size: 11px;
border-radius: 4px; padding: 1px 6px; margin-right: 4px;
}
.hint { font-size: 12.5px; color: var(--text-2); line-height: 1.6; }
.ok-chip {
display: inline-flex; align-items: center; gap: 5px;
background: #f6ffed; border: 1px solid #b7eb8f; color: #389e0d;
border-radius: 6px; padding: 4px 9px; font-size: 12.5px;
}
.warn-box {
background: var(--warn-bg); border: 1px solid var(--warn-border); color: var(--warn-text);
border-radius: 6px; padding: 7px 10px; font-size: 12.5px; margin-top: 6px; line-height: 1.6;
}
.empty {
text-align: center; color: var(--text-2); font-size: 13px;
padding: 22px 0; background: var(--card-soft); border-radius: 6px;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>