Files
ozon-seller-kit/extension-v2/entrypoints/sidepanel/App.tsx
T

711 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string> {
const sel = new Set<string>();
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<ScanResult | null> {
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<boolean> {
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<ScanResult | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [params, setParams] = useState<Array<{ key: string; value: string }>>([]);
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<ExportResult | null>(null);
const [uploadResult, setUploadResult] = useState<{ product_id: string; stage: string; assets_queued: number } | null>(null);
// 服务端设置(上传用)
const [settings, setSettings] = useState<BackendSettings | null>(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 (
<div style={{ minHeight: '100vh' }}>
{/* 顶部固定:标题 + 采集按钮 */}
<div style={{ position: 'sticky', top: 0, zIndex: 20, background: '#f5f5f5', padding: '12px 12px 8px', borderBottom: '1px solid #f0f0f0' }}>
<div style={{ marginBottom: 8 }}>
<Title level={4} style={{ margin: 0 }}>
🪆 套娃采集助手
</Title>
<Text type="secondary" style={{ fontSize: 12 }}>
采集 Ozon 商品页 · 导出到本地或上传服务端
</Text>
</div>
{/* 采集按钮 */}
<Button
type="primary"
block
size="large"
icon={<ScanOutlined />}
loading={status === '采集中'}
onClick={handleScan}
>
{status === '采集中' ? '采集中…' : '开始采集当前页'}
</Button>
</div>
<div style={{ padding: '0 12px 12px' }}>
{/* 服务端设置 */}
<Collapse
ghost
size="small"
style={{ marginTop: 8 }}
items={[
{
key: 'settings',
label: (
<Space size={4}>
<SettingOutlined />
<span style={{ fontSize: 12 }}>服务端设置(上传用)</span>
</Space>
),
children: (
<div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>后端地址</Text>
<Input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://127.0.0.1:8800"
/>
</div>
<div style={{ marginBottom: 8 }}>
<Text style={{ fontSize: 12 }}>访问 Token(可选,留空即可)</Text>
<Input.Password
value={appToken}
onChange={(e) => setAppToken(e.target.value)}
placeholder="后续加账户体系时再填"
/>
</div>
<Button size="small" onClick={onSaveSettings}>
保存设置
</Button>
</div>
),
},
]}
/>
{error && <Alert type="error" showIcon message={error} style={{ marginTop: 8 }} />}
{result && (
<>
<Card size="small" style={{ marginTop: 12 }} title="采集信息(可修改)">
<div style={{ marginBottom: 8 }}>
<Space wrap size={4}>
<Tag color="purple">{result.platform.toUpperCase()}</Tag>
{result.itemId && <Tag>{result.itemId}</Tag>}
<Tag color="blue">来源:{result.source}</Tag>
</Space>
</div>
<Form form={form} layout="vertical" size="small">
<Form.Item label="标题" name="title">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Row gutter={8}>
<Col span={12}>
<Form.Item label="价格" name="price">
<Input />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="品牌" name="brand">
<Input />
</Form.Item>
</Col>
</Row>
<Form.Item label="包装重量" name="packWeight">
<Input placeholder="如 3.5 кг" />
</Form.Item>
<Form.Item label="包装尺寸(长 × 宽 × 高)">
<Space.Compact block>
<Form.Item name="packLen" noStyle>
<Input placeholder="长" />
</Form.Item>
<Form.Item name="packWidth" noStyle>
<Input placeholder="宽" />
</Form.Item>
<Form.Item name="packHeight" noStyle>
<Input placeholder="高" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item label="卖点" name="sellingPoints">
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} />
</Form.Item>
<Form.Item label="描述" name="desc">
<Input.TextArea autoSize={{ minRows: 3, maxRows: 8 }} />
</Form.Item>
{/* 参数表(左侧参数名只读,右侧参数值可编辑) */}
{params.length > 0 && (
<Collapse
ghost
size="small"
items={[
{
key: 'params',
label: <Text style={{ fontSize: 12 }}>参数表({params.length} 项)</Text>,
children: (
<div>
{params.map((p, i) => (
<Row key={i} gutter={8} align="middle" style={{ marginBottom: 6 }}>
<Col span={10}>
<Text type="secondary" style={{ fontSize: 12, wordBreak: 'break-all', display: 'block' }}>
{p.key || '—'}
</Text>
</Col>
<Col span={14}>
<Input
size="small"
value={p.value}
onChange={(e) => {
const next = [...params];
next[i] = { ...next[i], value: e.target.value };
setParams(next);
}}
/>
</Col>
</Row>
))}
</div>
),
},
]}
/>
)}
</Form>
</Card>
{/* 图片分组 */}
<Card size="small" style={{ marginTop: 12 }} title={`图片素材(已选 ${selectedCount} 张)`}>
{groups.map((g) => {
const allOn = g.items.every((i) => selected.has(i.key));
return (
<div key={g.key} style={{ marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6 }}>
<Text strong style={{ fontSize: 12 }}>
{g.name} ({g.items.length})
</Text>
<a style={{ fontSize: 12 }} onClick={() => toggleGroup(g.items)}>
{allOn ? '取消全选' : '全选'}
</a>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{g.items.map((img) => {
const on = selected.has(img.key);
return (
<div
key={img.key}
onClick={() => 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' ? (
<div style={{ position: 'relative', width: '100%', height: '100%', background: '#eee' }}>
{img.thumbUrl && !/\.(mp4|webm|m3u8|mov|avi)(\?|$)/i.test(img.thumbUrl) ? (
<img src={img.thumbUrl} alt="视频封面" style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
) : null}
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.25)' }}>
<span style={{ color: '#fff', fontSize: 20, lineHeight: 1 }}></span>
</div>
</div>
) : (
<img src={img.thumbUrl} alt={img.variantName ?? g.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} loading="lazy" />
)}
{on && (
<div style={{ position: 'absolute', inset: 0, background: `rgba(139,92,246,0.15)` }}>
<span style={{ position: 'absolute', top: 2, right: 5, color: token.colorPrimary, fontSize: 14 }}></span>
</div>
)}
{img.variantName && (
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: 'rgba(0,0,0,0.5)', color: '#fff', fontSize: 9, padding: '1px 2px', textAlign: 'center', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{img.variantName}
</div>
)}
</div>
);
})}
</div>
</div>
);
})}
</Card>
{/* 警告 */}
{result.warnings.length > 0 && (
<div style={{ marginTop: 12 }}>
{result.warnings.map((w, i) => (
<Alert key={i} type="warning" showIcon message={w} style={{ marginBottom: 4 }} />
))}
</div>
)}
<div style={{ position: 'sticky', bottom: 0, background: '#f5f5f5', padding: '12px 0', zIndex: 10, marginTop: 12, borderTop: '1px solid #f0f0f0' }}>
{/* 本地文件夹名 + 导出/上传(固定在底部) */}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<Text style={{ fontSize: 12, whiteSpace: 'nowrap' }}>本地文件夹名</Text>
<Input
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
placeholder="留空用商品标题"
size="small"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
<div style={{ fontSize: 11, color: '#888', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{rootLabel ? `保存到:${rootLabel}` : '未选择保存目录'}
</div>
<Button size="small" onClick={handlePickDir}>
{rootLabel ? '更换目录' : '选择目录'}
</Button>
</div>
<Row gutter={8}>
<Col span={12}>
<Button
block
icon={<DownloadOutlined />}
onClick={handleExport}
loading={exporting}
disabled={selectedCount === 0}
>
导出到本地
</Button>
</Col>
<Col span={12}>
<Button
block
type="primary"
icon={<CloudUploadOutlined />}
onClick={handleUpload}
loading={uploading}
disabled={selectedCount === 0}
>
上传服务端
</Button>
</Col>
</Row>
{exportResult && (
<Alert
type={exportResult.failed.length ? 'warning' : 'success'}
showIcon
style={{ marginTop: 8 }}
message={`已写入 ${exportResult.written} 张图片到「${exportResult.folderName}${exportResult.failed.length ? `${exportResult.failed.length} 张失败` : ''}`}
/>
)}
{uploadResult && (
<Alert
type="success"
showIcon
style={{ marginTop: 8 }}
message={`已上传服务端,商品已进入采集箱(素材 ${uploadResult.assets_queued} 张,后台转存中)`}
/>
)}
</div>
</>
)}
{!result && (
<div style={{ marginTop: 16, padding: 12, background: '#fafafa', borderRadius: 8, fontSize: 12, color: '#888' }}>
<div>💡 使用说明:</div>
<ol style={{ margin: '4px 0 0 18px', padding: 0 }}>
<li>打开 Ozon 商品详情页(ru/kz/by</li>
<li>滚动到页面底部(加载详情图)</li>
<li>点「开始采集」→ 核对/修改信息 导出或上传</li>
</ol>
</div>
)}
</div>
</div>
);
}
function Root() {
return (
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#8b5cf6',
borderRadius: 8,
},
}}
>
<AntdApp>
<Panel />
</AntdApp>
</ConfigProvider>
);
}
const root = createRoot(document.getElementById('root')!);
root.render(<Root />);
export default Root;