711 lines
32 KiB
TypeScript
711 lines
32 KiB
TypeScript
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;
|