feat: 开发采集、采集箱和商品编辑功能

This commit is contained in:
Joey
2026-08-15 22:17:26 +08:00
parent c61d1a3154
commit 36357843d0
130 changed files with 18005 additions and 12 deletions
+710
View File
@@ -0,0 +1,710 @@
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;