import { createRoot } from 'react-dom/client'; import { useEffect, useMemo, useState } from 'react'; import { App as AntdApp, Alert, Button, Card, Collapse, ConfigProvider, Form, Input, Row, Col, Space, Tag, Typography, theme, } from 'antd'; import zhCN from 'antd/locale/zh_CN'; import { CloudUploadOutlined, DownloadOutlined, ScanOutlined, SettingOutlined, } from '@ant-design/icons'; import type { ScanResult, ImageMaterial } from '../../src/collector/scan'; import { cleanFilename } from '../../src/collector/url'; import { buildProduct, type TextEdits } from '../../src/export/builder'; import { chooseRootDir, writeProductFolder, type ExportResult } from '../../src/export/filesystem'; import { loadRootDir } from '../../src/export/idb'; import { buildMaterialsPayload } from '../../src/api/client'; import { loadSettings, saveSettings, type BackendSettings } from '../../src/storage/settings'; const { Title, Text } = Typography; const GROUP_ORDER: Array<{ key: ImageMaterial['groupKey']; name: string }> = [ { key: 'main', name: '主图' }, { key: 'sku', name: 'SKU图' }, { key: 'detail', name: '详情图' }, { key: 'video', name: '视频' }, ]; function defaultSelection(result: ScanResult): Set { const sel = new Set(); let detailCount = 0; for (const img of result.images) { if (img.groupKey === 'detail') { if (detailCount < 3) sel.add(img.key); detailCount++; } else { sel.add(img.key); } } return sel; } function norm(s: string): string { return s.toLowerCase().trim().replace(/[,,::()()]/g, ''); } /** 从参数表里抽出「包装重量 + 包装尺寸(长宽高)」,其余参数保留 */ function extractWeightAndDims(pairs: Array<{ key: string; value: string }>): { weight: string; dims: { l: string; w: string; h: string }; dimsUnit: 'mm' | 'cm'; remaining: Array<{ key: string; value: string }>; } { // 包装重量:优先「包装重量」,其次「重量 / вес」 const weightP = pairs.find((p) => { const k = norm(p.key); return k.includes('包装重量') || k.includes('вес упаковки'); }) ?? pairs.find((p) => { const k = norm(p.key); return k.includes('重量') || k.includes('вес'); }); // 分开的长/宽/高(用「长度/宽度/高度」而非「长/宽/高」,避免误匹配「长X宽x高」这种合并键) const lenP = pairs.find((p) => { const k = norm(p.key); return k.includes('包装长度') || k.includes('长度') || k.includes('длина'); }); const widP = pairs.find((p) => { const k = norm(p.key); return k.includes('包装宽度') || k.includes('宽度') || k.includes('ширина'); }); const heiP = pairs.find((p) => { const k = norm(p.key); return k.includes('包装高度') || k.includes('高度') || k.includes('высота'); }); let l = lenP?.value ?? ''; let w = widP?.value ?? ''; let h = heiP?.value ?? ''; let dimsUnit: 'mm' | 'cm' = 'cm'; // 合并的「包装尺寸(长X宽x高),厘米 = 48*18*25」→ 拆分 let dimP: { key: string; value: string } | undefined; if (!l && !w && !h) { dimP = pairs.find((p) => norm(p.key).includes('包装尺寸')); if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('размер') || norm(p.key).includes('габарит')); if (!dimP) dimP = pairs.find((p) => norm(p.key).includes('尺寸')); if (dimP) { const isMm = /(мм|mm|毫米)/.test(`${dimP.key} ${dimP.value}`.toLowerCase()); dimsUnit = isMm ? 'mm' : 'cm'; const nums = dimP.value.match(/\d+(?:[.,]\d+)?/g) ?? []; if (nums.length >= 3) { l = nums[0] ?? ''; w = nums[1] ?? ''; h = nums[2] ?? ''; } } } else { const combined = `${lenP?.key ?? ''} ${lenP?.value ?? ''} ${widP?.value ?? ''} ${heiP?.value ?? ''}`.toLowerCase(); dimsUnit = /(мм|mm|毫米)/.test(combined) ? 'mm' : 'cm'; } // 其余参数保留(去掉已抽走的重量/尺寸项) const used = new Set([weightP, lenP, widP, heiP, dimP].filter(Boolean)); const remaining = pairs.filter((p) => !used.has(p)); return { weight: weightP?.value ?? '', dims: { l, w, h }, dimsUnit, remaining }; } /** 调用页面里的采集入口,返回 ScanResult 或 null(未加载/不支持) */ async function scanTab(tabId: number): Promise { try { const [r] = await chrome.scripting.executeScript({ target: { tabId }, func: () => (window as any).__SellerHelperOzon?.scan?.(), }); return (r?.result as ScanResult) ?? null; } catch { return null; } } /** 判断当前 tab 是否 Ozon 商品详情页 */ async function isProductPage(tabId: number): Promise { try { const [r] = await chrome.scripting.executeScript({ target: { tabId }, func: () => /\/product\/[^/]+-\d+\/?/.test(location.pathname) || /\/context\/detail\/id\/\d+/.test(location.pathname), }); return !!r?.result; } catch { return false; } } function Panel() { const { message } = AntdApp.useApp(); const { token } = theme.useToken(); const [form] = Form.useForm(); const [status, setStatus] = useState(''); const [error, setError] = useState(''); const [result, setResult] = useState(null); const [selected, setSelected] = useState>(new Set()); const [params, setParams] = useState>([]); const [dimsUnit, setDimsUnit] = useState<'mm' | 'cm'>('cm'); const [folderName, setFolderName] = useState(''); const [rootLabel, setRootLabel] = useState(''); const [exporting, setExporting] = useState(false); const [uploading, setUploading] = useState(false); const [exportResult, setExportResult] = useState(null); const [uploadResult, setUploadResult] = useState<{ product_id: string; stage: string; assets_queued: number } | null>(null); // 服务端设置(上传用) const [settings, setSettings] = useState(null); const [baseUrl, setBaseUrl] = useState('http://127.0.0.1:8800'); const [appToken, setAppToken] = useState(''); useEffect(() => { loadRootDir().then((h) => setRootLabel(h ? h.name : '')); loadSettings().then((s) => { setSettings(s); setBaseUrl(s.baseUrl); setAppToken(s.token); }); }, []); const groups = useMemo(() => { if (!result) return []; return GROUP_ORDER.map((g) => ({ ...g, items: result.images.filter((img) => img.groupKey === g.key), })).filter((g) => g.items.length > 0); }, [result]); const selectedCount = useMemo(() => { if (!result) return 0; return result.images.filter((img) => selected.has(img.key)).length; }, [result, selected]); const onSaveSettings = async () => { const s: BackendSettings = { baseUrl: baseUrl.trim(), token: appToken.trim() }; await saveSettings(s); setSettings(s); message.success('服务端设置已保存'); }; const handleScan = async () => { setError(''); setResult(null); setExportResult(null); setUploadResult(null); setStatus('采集中'); try { const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); if (!tab?.id) { setError('无法获取当前标签页'); setStatus(''); return; } let data = await scanTab(tab.id); // 内容脚本没加载(页面在插件安装/重载前就打开了)→ 手动注入后重试 if (!data) { try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content-scripts/content.js'], }); await new Promise((r) => setTimeout(r, 300)); data = await scanTab(tab.id); } catch { /* 注入失败忽略,走下方错误提示 */ } } if (!data) { const isProduct = await isProductPage(tab.id); setError( isProduct ? '采集失败:内容脚本未生效,请刷新商品页后重试' : '当前页面不是 Ozon 商品详情页,请打开一个商品页后再采集', ); setStatus(''); return; } setResult(data); setSelected(defaultSelection(data)); setFolderName(cleanFilename(data.texts.find((t) => t.kind === 'title')?.content ?? '') || ''); const rawParams = data.texts.find((t) => t.kind === 'params')?.pairs ?? []; const { weight, dims, dimsUnit: du, remaining } = extractWeightAndDims(rawParams); setParams(remaining); setDimsUnit(du); // 填表单 form.setFieldsValue({ title: data.texts.find((t) => t.kind === 'title')?.content ?? '', price: data.texts.find((t) => t.kind === 'price')?.content ?? '', brand: data.texts.find((t) => t.kind === 'brand')?.content || '无品牌', sellingPoints: data.texts.find((t) => t.kind === 'selling_point')?.content ?? '', desc: data.texts.find((t) => t.kind === 'desc')?.content ?? '', packWeight: weight, packLen: dims.l, packWidth: dims.w, packHeight: dims.h, }); setStatus('采集完成'); } catch (err) { setError(`采集失败: ${err instanceof Error ? err.message : String(err)}`); setStatus(''); } }; const handlePickDir = async () => { setError(''); try { // 始终弹选择器(选择或更换目录) const handle = await chooseRootDir(); setRootLabel(handle.name); message.success(`已选择目录「${handle.name}」`); } catch (err) { // 用户取消选择(AbortError)不算错误,静默处理 if (err instanceof DOMException && err.name === 'AbortError') { return; } setError(`选择目录失败: ${err instanceof Error ? err.message : String(err)}`); } }; const toggleOne = (key: string) => { setSelected((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); }; const toggleGroup = (items: ImageMaterial[]) => { setSelected((prev) => { const next = new Set(prev); const allOn = items.every((i) => next.has(i.key)); for (const i of items) { if (allOn) next.delete(i.key); else next.add(i.key); } return next; }); }; const getEdits = (): TextEdits => { const v = form.getFieldsValue(); return { title: v.title, price: v.price, brand: v.brand, sellingPoints: v.sellingPoints, desc: v.desc, params, weight: v.packWeight, dims: { l: v.packLen, w: v.packWidth, h: v.packHeight }, dimsUnit, }; }; const handleExport = async () => { if (!result) return; if (selectedCount === 0) { setError('请至少勾选一张图片'); return; } setExporting(true); setError(''); setExportResult(null); try { const name = folderName.trim() || `ozon-${result.itemId ?? 'product'}`; const built = buildProduct(result, selected, getEdits()); const res = await writeProductFolder(name, built.product, built.sources, built.files); setExportResult(res); message.success('已导出到本地'); } catch (err) { setError(`导出失败: ${err instanceof Error ? err.message : String(err)}`); } finally { setExporting(false); } }; const handleUpload = async () => { if (!result) return; if (selectedCount === 0) { setError('请至少勾选一张图片'); return; } setUploading(true); setError(''); setUploadResult(null); try { const payload = buildMaterialsPayload(result, selected, getEdits()); const resp = await chrome.runtime.sendMessage({ action: 'uploadMaterials', baseUrl: settings?.baseUrl ?? 'http://127.0.0.1:8800', token: settings?.token ?? '', payload, }); if (!resp?.ok) throw new Error(resp?.error ?? '上传失败'); setUploadResult(resp.data); message.success('已上传服务端,进入采集箱'); } catch (err) { setError(`上传失败: ${err instanceof Error ? err.message : String(err)}`); } finally { setUploading(false); } }; return (
{/* 顶部固定:标题 + 采集按钮 */}
🪆 套娃采集助手 采集 Ozon 商品页 · 导出到本地或上传服务端
{/* 采集按钮 */}
{/* 服务端设置 */} 服务端设置(上传用) ), children: (
后端地址 setBaseUrl(e.target.value)} placeholder="http://127.0.0.1:8800" />
访问 Token(可选,留空即可) setAppToken(e.target.value)} placeholder="后续加账户体系时再填" />
), }, ]} /> {error && } {result && ( <>
{result.platform.toUpperCase()} {result.itemId && {result.itemId}} 来源:{result.source}
{/* 参数表(左侧参数名只读,右侧参数值可编辑) */} {params.length > 0 && ( 参数表({params.length} 项), children: (
{params.map((p, i) => ( {p.key || '—'} { const next = [...params]; next[i] = { ...next[i], value: e.target.value }; setParams(next); }} /> ))}
), }, ]} /> )}
{/* 图片分组 */} {groups.map((g) => { const allOn = g.items.every((i) => selected.has(i.key)); return (
{g.items.map((img) => { const on = selected.has(img.key); return (
toggleOne(img.key)} style={{ position: 'relative', width: 64, height: 64, border: on ? `2px solid ${token.colorPrimary}` : '1px solid #e0e0e0', borderRadius: 8, overflow: 'hidden', cursor: 'pointer', background: '#f5f5f5', }} > {img.type === 'video' ? (
{img.thumbUrl && !/\.(mp4|webm|m3u8|mov|avi)(\?|$)/i.test(img.thumbUrl) ? ( 视频封面 ) : null}
) : ( {img.variantName )} {on && (
)} {img.variantName && (
{img.variantName}
)}
); })}
); })}
{/* 警告 */} {result.warnings.length > 0 && (
{result.warnings.map((w, i) => ( ))}
)}
{/* 本地文件夹名 + 导出/上传(固定在底部) */}
本地文件夹名 setFolderName(e.target.value)} placeholder="留空用商品标题" size="small" />
{rootLabel ? `保存到:${rootLabel}` : '未选择保存目录'}
{exportResult && ( )} {uploadResult && ( )}
)} {!result && (
💡 使用说明:
  1. 打开 Ozon 商品详情页(ru/kz/by)
  2. 滚动到页面底部(加载详情图)
  3. 点「开始采集」→ 核对/修改信息 → 导出或上传
)}
); } function Root() { return ( ); } const root = createRoot(document.getElementById('root')!); root.render(); export default Root;