feat: 开发采集、采集箱和商品编辑功能
This commit is contained in:
@@ -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;
|
||||
Reference in New Issue
Block a user