Update App.jsx via upload script - 2026-08-07 17:53:15

This commit is contained in:
mike 2026-08-07 17:53:37 +08:00
parent f655d7cfd7
commit 8ef989ade4
1 changed files with 386 additions and 5 deletions

391
App.jsx
View File

@ -248,7 +248,7 @@ const COUNTRIES = [
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines' };
const STORAGE_KEYS = { customers: 'crm:customers', products: 'crm:products', settings: 'crm:settings', quotes: 'crm:quotes', sellerProfiles: 'crm:sellerProfiles', certifications: 'crm:certifications', productCategories: 'crm:productCategories', packingLists: 'crm:packingLists', catalogDocs: 'crm:catalogDocs', jmsLines: 'crm:jmsLines', backPanelTemplates: 'crm:backPanelTemplates' };
const blankCustomer = () => ({
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
@ -278,6 +278,135 @@ const EXPORT_LANGUAGES = [
// PDF + /
// AIDeepSeek/
// AI resolveExportTranslations
/* ---------------------------- 后板图图标库 ---------------------------- */
// ///USB/RJ45/
// + 线SVGviewBox
// 0 0 40 40currentColor便便
const BACK_PANEL_ICON_LIBRARY = [
{ type: 'ac_input', label: '交流输入口', group: '基础元件' },
{ type: 'fuse_breaker', label: '保险丝/断路器', group: '基础元件' },
{ type: 'output_generic', label: '输出插座(通用)', group: '基础元件' },
{ type: 'usb', label: 'USB', group: '基础元件' },
{ type: 'rj45', label: 'RJ45', group: '基础元件' },
{ type: 'fan', label: '风扇', group: '基础元件' },
{ type: 'socket_cn', label: '国标插座', group: '可选配插座' },
{ type: 'socket_schuko', label: '德标插座(Schuko)', group: '可选配插座' },
{ type: 'socket_uk', label: '英标插座(BS1363)', group: '可选配插座' },
{ type: 'socket_us', label: '美标插座(NEMA)', group: '可选配插座' },
{ type: 'socket_au', label: '澳标插座', group: '可选配插座' },
{ type: 'socket_za', label: '南非/印度插座', group: '可选配插座' },
{ type: 'socket_it', label: '意标插座', group: '可选配插座' },
{ type: 'socket_iec', label: 'IEC C13/C14', group: '可选配插座' },
];
const BACK_PANEL_ICON_LABELS = BACK_PANEL_ICON_LIBRARY.reduce((m, i) => ({ ...m, [i.type]: i.label }), {});
function BackPanelIconSVG({ type, size = 40 }) {
const stroke = { stroke: 'currentColor', strokeWidth: 2, fill: 'none' };
const body = (() => {
switch (type) {
case 'ac_input':
return (<>
<rect x="6" y="6" width="28" height="28" rx="3" {...stroke} />
<rect x="14" y="12" width="3" height="10" fill="currentColor" />
<rect x="23" y="12" width="3" height="10" fill="currentColor" />
<rect x="14" y="26" width="12" height="3" fill="currentColor" />
</>);
case 'fuse_breaker':
return (<>
<circle cx="20" cy="20" r="15" {...stroke} />
<line x1="20" y1="20" x2="28" y2="11" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
<circle cx="20" cy="20" r="2" fill="currentColor" />
</>);
case 'output_generic':
return (<>
<rect x="4" y="10" width="32" height="20" rx="3" {...stroke} />
<rect x="14" y="16" width="3" height="8" fill="currentColor" />
<rect x="23" y="16" width="3" height="8" fill="currentColor" />
</>);
case 'usb':
return (<>
<rect x="8" y="14" width="24" height="12" rx="2" {...stroke} />
<rect x="13" y="17" width="4" height="6" fill="currentColor" />
<rect x="23" y="17" width="4" height="6" fill="currentColor" />
</>);
case 'rj45':
return (<>
<rect x="8" y="10" width="24" height="18" rx="2" {...stroke} />
<rect x="16" y="6" width="8" height="6" {...stroke} />
{[0, 1, 2, 3, 4, 5].map(i => (<line key={i} x1={11 + i * 3.4} y1="22" x2={11 + i * 3.4} y2="27" stroke="currentColor" strokeWidth="1.5" />))}
</>);
case 'fan':
return (<>
<circle cx="20" cy="20" r="15" {...stroke} />
{[0, 90, 180, 270].map(a => (
<ellipse key={a} cx="20" cy="11" rx="4.5" ry="8" fill="currentColor" opacity="0.85" transform={`rotate(${a} 20 20)`} />
))}
<circle cx="20" cy="20" r="3" fill="var(--panel, #fff)" stroke="currentColor" strokeWidth="1.5" />
</>);
case 'socket_cn':
return (<>
<rect x="5" y="5" width="30" height="30" rx="4" {...stroke} />
<line x1="14" y1="14" x2="20" y2="19" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<line x1="26" y1="14" x2="20" y2="19" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<circle cx="20" cy="27" r="2.2" fill="currentColor" />
</>);
case 'socket_schuko':
return (<>
<circle cx="20" cy="20" r="15" {...stroke} />
<circle cx="15" cy="20" r="2.2" fill="currentColor" />
<circle cx="25" cy="20" r="2.2" fill="currentColor" />
<rect x="3" y="17" width="4" height="6" fill="currentColor" />
<rect x="33" y="17" width="4" height="6" fill="currentColor" />
</>);
case 'socket_uk':
return (<>
<rect x="5" y="5" width="30" height="30" rx="4" {...stroke} />
<rect x="18" y="10" width="4" height="8" fill="currentColor" />
<rect x="11" y="23" width="4" height="8" fill="currentColor" />
<rect x="25" y="23" width="4" height="8" fill="currentColor" />
</>);
case 'socket_us':
return (<>
<rect x="7" y="5" width="26" height="30" rx="5" {...stroke} />
<rect x="13" y="12" width="3" height="8" fill="currentColor" />
<rect x="24" y="12" width="3" height="8" fill="currentColor" />
<circle cx="20" cy="27" r="2.2" fill="currentColor" />
</>);
case 'socket_au':
return (<>
<rect x="5" y="5" width="30" height="30" rx="4" {...stroke} />
<line x1="14" y1="13" x2="19" y2="19" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<line x1="26" y1="13" x2="21" y2="19" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
<rect x="18" y="24" width="4" height="7" fill="currentColor" />
</>);
case 'socket_za':
return (<>
<circle cx="20" cy="20" r="15" {...stroke} />
<circle cx="20" cy="13" r="2.4" fill="currentColor" />
<circle cx="14" cy="24" r="2.4" fill="currentColor" />
<circle cx="26" cy="24" r="2.4" fill="currentColor" />
</>);
case 'socket_it':
return (<>
<circle cx="20" cy="20" r="15" {...stroke} />
<circle cx="20" cy="12" r="2.2" fill="currentColor" />
<circle cx="20" cy="20" r="2.2" fill="currentColor" />
<circle cx="20" cy="28" r="2.2" fill="currentColor" />
</>);
case 'socket_iec':
return (<>
<rect x="6" y="6" width="28" height="28" rx="3" {...stroke} />
<rect x="13" y="12" width="3" height="9" fill="currentColor" />
<rect x="24" y="12" width="3" height="9" fill="currentColor" />
<rect x="15" y="25" width="10" height="3" fill="currentColor" />
</>);
default:
return <rect x="6" y="6" width="28" height="28" rx="3" {...stroke} />;
}
})();
return <svg viewBox="0 0 40 40" width={size} height={size}>{body}</svg>;
}
const TRANSLATION_DICTIONARY = {
//
'产品规格书': { en: 'Specifications', fr: 'Spécifications', ru: 'Технические характеристики', es: 'Especificaciones', ar: 'المواصفات' },
@ -802,6 +931,7 @@ const blankProduct = () => ({
applicationScenarios: '', // ""
container20ft: '', container40ft: '', container40hq: '', // 20/40/40
extraSpecs: [], // AI {label, value} [{label:'', value:'13.7VDC/'}]
backPanelTemplateId: '', // idUPS
productImageFileName: '', productImageOriginalName: '', // PDF
sellingPoints: '', // /costPrice/notes
costPrice: '', notes: '',
@ -1322,7 +1452,12 @@ async function loadAll() {
const jl = await storageGet(STORAGE_KEYS.jmsLines);
if (jl && jl.value) jmsLines = JSON.parse(jl.value);
} catch (e) {}
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines };
let backPanelTemplates = [];
try {
const bpt = await storageGet(STORAGE_KEYS.backPanelTemplates);
if (bpt && bpt.value) backPanelTemplates = JSON.parse(bpt.value);
} catch (e) {}
return { customers, products, settings, quotes, sellerProfiles, certifications, productCategories, packingLists, catalogDocs, jmsLines, backPanelTemplates };
}
/* -------------------------------- styles -------------------------------- */
@ -1816,6 +1951,7 @@ const NAV_GROUPS = [
{ label: '产品', items: [
{ id: 'groups', label: '产品分组', icon: FolderTree },
{ id: 'products', label: '产品管理', icon: Package },
{ id: 'backpanel', label: '后板图模板', icon: Settings2 },
] },
];
const STANDALONE_NAV = [
@ -1997,6 +2133,7 @@ function MainApp({ username, onLogout }) {
const [catalogEditDraft, setCatalogEditDraft] = useState({ title: '', category: '', brand: '', type: '' });
const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all');
const [jmsLines, setJmsLines] = useState([]);
const [backPanelTemplates, setBackPanelTemplates] = useState([]);
const [telegramConfigHasToken, setTelegramConfigHasToken] = useState(false);
const [telegramConfigChatId, setTelegramConfigChatId] = useState('');
@ -2023,6 +2160,7 @@ function MainApp({ username, onLogout }) {
setPackingLists(data.packingLists);
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
setJmsLines(data.jmsLines || []);
setBackPanelTemplates(data.backPanelTemplates || []);
setLoaded(true);
})();
}, []);
@ -2042,6 +2180,7 @@ function MainApp({ username, onLogout }) {
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.packingLists, JSON.stringify(packingLists)).catch(() => {}); }, [packingLists, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.catalogDocs, JSON.stringify(catalogDocs)).catch(() => {}); }, [catalogDocs, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.jmsLines, JSON.stringify(jmsLines)).catch(() => {}); }, [jmsLines, loaded]);
useEffect(() => { if (loaded) storageSet(STORAGE_KEYS.backPanelTemplates, JSON.stringify(backPanelTemplates)).catch(() => {}); }, [backPanelTemplates, loaded]);
function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
@ -3311,7 +3450,7 @@ function MainApp({ username, onLogout }) {
<div>
{productDraft ? (
<ProductForm draft={productDraft} setDraft={setProductDraft} onSave={saveProductDraft} onCancel={cancelProductDraft} categories={productCategories} onManage={(mode) => setTaxManageMode(mode)} onNotify={notify} />
<ProductForm draft={productDraft} setDraft={setProductDraft} onSave={saveProductDraft} onCancel={cancelProductDraft} categories={productCategories} onManage={(mode) => setTaxManageMode(mode)} onNotify={notify} backPanelTemplates={backPanelTemplates} />
) : selectedProduct ? (
<ProductDetail
product={selectedProduct} onEdit={() => startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)}
@ -3319,6 +3458,7 @@ function MainApp({ username, onLogout }) {
exportLang={productExportLang} onExportLangChange={setProductExportLang}
exportLabels={productExportLabels} exportLoading={productExportLoading}
onExportPdf={handleExportProductPdf} exportPdfBusy={productExportPdfBusy}
backPanelTemplates={backPanelTemplates}
/>
) : (
<div className="panel"><div className="empty-state"><Package size={28} />选择左侧产品查看详情或新建一个产品档案</div></div>
@ -3327,6 +3467,10 @@ function MainApp({ username, onLogout }) {
</div>
)}
{view === 'backpanel' && (
<BackPanelStudio templates={backPanelTemplates} setTemplates={setBackPanelTemplates} onNotify={notify} />
)}
{view === 'catalog' && (
<div
className={`catalog-dropzone ${catalogDragActive ? 'drag-active' : ''}`}
@ -4614,7 +4758,227 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
/* ----------------------------- product form / detail ----------------------------- */
function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage, onNotify }) {
/* ---------------------------- 后板图模板编辑器 ---------------------------- */
// backPanelTemplateId
// 1200/1600/2200
//
function blankBackPanelTemplate() {
return { id: 'bpt_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7), name: '未命名模板', width: 640, height: 360, elements: [] };
}
function BackPanelStudio({ templates, setTemplates, onNotify }) {
const [editingId, setEditingId] = useState(null);
const [selectedElId, setSelectedElId] = useState(null);
const editing = (templates || []).find(t => t.id === editingId) || null;
function updateEditing(patch) {
setTemplates(prev => prev.map(t => (t.id === editingId ? { ...t, ...patch } : t)));
}
function addElement(type, x, y) {
const el = { id: 'el_' + Date.now() + '_' + Math.random().toString(36).slice(2, 6), type, x, y, size: 40, rotation: 0 };
updateEditing({ elements: [...(editing.elements || []), el] });
setSelectedElId(el.id);
}
function updateElement(id, patch) {
updateEditing({ elements: (editing.elements || []).map(el => (el.id === id ? { ...el, ...patch } : el)) });
}
function removeElement(id) {
updateEditing({ elements: (editing.elements || []).filter(el => el.id !== id) });
if (selectedElId === id) setSelectedElId(null);
}
function handleCanvasDrop(e) {
e.preventDefault();
const type = e.dataTransfer.getData('text/plain');
if (!type) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = Math.round(e.clientX - rect.left);
const y = Math.round(e.clientY - rect.top);
addElement(type, x, y);
}
function handleElementPointerDown(e, el) {
e.stopPropagation();
setSelectedElId(el.id);
const canvasEl = e.currentTarget.closest('.backpanel-canvas');
const canvasRect = canvasEl.getBoundingClientRect();
const startOffsetX = e.clientX - canvasRect.left - el.x;
const startOffsetY = e.clientY - canvasRect.top - el.y;
e.currentTarget.setPointerCapture(e.pointerId);
const onMove = (moveEvt) => {
const nx = Math.round(moveEvt.clientX - canvasRect.left - startOffsetX);
const ny = Math.round(moveEvt.clientY - canvasRect.top - startOffsetY);
updateElement(el.id, { x: nx, y: ny });
};
const onUp = (upEvt) => {
e.currentTarget.releasePointerCapture(e.pointerId);
e.currentTarget.removeEventListener('pointermove', onMove);
e.currentTarget.removeEventListener('pointerup', onUp);
};
e.currentTarget.addEventListener('pointermove', onMove);
e.currentTarget.addEventListener('pointerup', onUp);
}
const selectedEl = editing && (editing.elements || []).find(el => el.id === selectedElId);
// ---------- ----------
if (!editing) {
return (
<div className="panel section-gap">
<div className="panel-header">
<div className="panel-title"><Settings2 size={15} />后板图模板</div>
<button
className="btn btn-primary btn-sm"
onClick={() => {
const t = blankBackPanelTemplate();
setTemplates(prev => [...prev, t]);
setEditingId(t.id);
setSelectedElId(null);
}}
><Plus size={13} />新建模板</button>
</div>
<div className="panel-body">
{(!templates || templates.length === 0) ? (
<div className="empty-state"><Settings2 size={26} />还没有后板图模板点击右上角新建一个</div>
) : templates.map(t => (
<div key={t.id} className="row" style={{ cursor: 'pointer' }} onClick={() => { setEditingId(t.id); setSelectedElId(null); }}>
<div className="led-dot blue" />
<div className="row-main">
<div className="row-title">{t.name || '(未命名模板)'}</div>
<div className="row-sub">{(t.elements || []).length} 个元素</div>
</div>
<button
className="btn btn-sm btn-ghost btn-danger"
onClick={(e) => {
e.stopPropagation();
if (!window.confirm(`确定删除模板"${t.name}"吗?引用了这个模板的产品会变成"未设置后板图"。`)) return;
setTemplates(prev => prev.filter(x => x.id !== t.id));
onNotify && onNotify('已删除模板');
}}
><Trash2 size={12} /></button>
</div>
))}
</div>
</div>
);
}
// ---------- ----------
return (
<div className="panel section-gap">
<div className="panel-header">
<input
className="input" style={{ fontWeight: 700, fontSize: 14, maxWidth: 320 }}
value={editing.name} onChange={e => updateEditing({ name: e.target.value })}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-sm" onClick={() => { setEditingId(null); setSelectedElId(null); onNotify && onNotify('模板已保存'); }}>
<Check size={13} />完成编辑
</button>
</div>
</div>
<div className="panel-body" style={{ display: 'flex', gap: 16, alignItems: 'flex-start' }}>
<div style={{ flexShrink: 0, width: 160 }}>
<div style={{ fontSize: 12, fontWeight: 700, color: 'var(--ink-soft)', marginBottom: 8 }}>拖拽图标到右侧画布</div>
{['基础元件', '可选配插座'].map(group => (
<div key={group} style={{ marginBottom: 14 }}>
<div style={{ fontSize: 11, color: 'var(--ink-soft)', marginBottom: 6 }}>{group}</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
{BACK_PANEL_ICON_LIBRARY.filter(i => i.group === group).map(icon => (
<div
key={icon.type}
draggable
onDragStart={e => e.dataTransfer.setData('text/plain', icon.type)}
title={icon.label}
style={{
border: '1px solid var(--line)', borderRadius: 8, padding: 6, cursor: 'grab',
display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3, background: 'var(--panel)',
}}
>
<BackPanelIconSVG type={icon.type} size={26} />
<span style={{ fontSize: 9.5, textAlign: 'center', lineHeight: 1.2 }}>{icon.label}</span>
</div>
))}
</div>
</div>
))}
</div>
<div style={{ flex: 1 }}>
<div
className="backpanel-canvas"
onDragOver={e => e.preventDefault()}
onDrop={handleCanvasDrop}
onPointerDown={() => setSelectedElId(null)}
style={{
position: 'relative', width: editing.width, height: editing.height, maxWidth: '100%',
background: '#3a3f47', borderRadius: 8, border: '1px solid var(--line)', overflow: 'hidden',
}}
>
{(editing.elements || []).map(el => (
<div
key={el.id}
onPointerDown={e => handleElementPointerDown(e, el)}
title={BACK_PANEL_ICON_LABELS[el.type] || el.type}
style={{
position: 'absolute', left: el.x, top: el.y, cursor: 'grab',
transform: `translate(-50%, -50%) rotate(${el.rotation || 0}deg)`,
color: selectedElId === el.id ? 'var(--blue)' : '#e8eaed',
outline: selectedElId === el.id ? '2px dashed var(--blue)' : 'none', outlineOffset: 3, borderRadius: 4,
}}
>
<BackPanelIconSVG type={el.type} size={el.size || 40} />
</div>
))}
{(editing.elements || []).length === 0 && (
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9aa0a6', fontSize: 12.5 }}>
这里代表机身后板轮廓把左边的图标拖进来开始摆放
</div>
)}
</div>
{selectedEl && (
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 10 }}>
<span style={{ fontSize: 12.5, fontWeight: 600 }}>{BACK_PANEL_ICON_LABELS[selectedEl.type] || selectedEl.type}</span>
<button className="btn btn-sm btn-ghost" onClick={() => updateElement(selectedEl.id, { rotation: ((selectedEl.rotation || 0) + 90) % 360 })}>旋转90°</button>
<button className="btn btn-sm btn-ghost" onClick={() => updateElement(selectedEl.id, { size: Math.max(20, (selectedEl.size || 40) - 6) })}>缩小</button>
<button className="btn btn-sm btn-ghost" onClick={() => updateElement(selectedEl.id, { size: Math.min(80, (selectedEl.size || 40) + 6) })}>放大</button>
<button className="btn btn-sm btn-ghost btn-danger" onClick={() => removeElement(selectedEl.id)}><Trash2 size={12} />删除</button>
</div>
)}
</div>
</div>
</div>
);
}
// /PDF
function BackPanelReadOnly({ template }) {
if (!template) return null;
return (
<div
style={{
position: 'relative', width: template.width, height: template.height, maxWidth: '100%',
background: '#3a3f47', borderRadius: 8, overflow: 'hidden', margin: '0 auto',
}}
>
{(template.elements || []).map(el => (
<div
key={el.id}
style={{
position: 'absolute', left: el.x, top: el.y,
transform: `translate(-50%, -50%) rotate(${el.rotation || 0}deg)`,
color: '#e8eaed',
}}
>
<BackPanelIconSVG type={el.type} size={el.size || 40} />
</div>
))}
</div>
);
}
function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage, onNotify, backPanelTemplates }) {
const set = (patch) => setDraft(d => ({ ...d, ...patch }));
const cat = (categories || []).find(c => c.id === draft.category);
const brandOptions = cat ? cat.brands : [];
@ -5068,6 +5432,16 @@ function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage,
</div>
)}
{isUps && !isMiniUps && (
<div className="field">
<span className="field-label">后板图模板可选跨产品共用同一个布局</span>
<select className="select" value={draft.backPanelTemplateId || ''} onChange={e => set({ backPanelTemplateId: e.target.value })}>
<option value="">未设置</option>
{(backPanelTemplates || []).map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
</select>
</div>
)}
<div className="field">
<div className="field-label-row">
<span className="field-label">产品主图可选用于多语言PDF导出</span>
@ -5156,13 +5530,14 @@ function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage,
);
}
function ProductDetail({ product: p, onEdit, onDelete, categories, exportLang, onExportLangChange, exportLabels, exportLoading, onExportPdf, exportPdfBusy }) {
function ProductDetail({ product: p, onEdit, onDelete, categories, exportLang, onExportLangChange, exportLabels, exportLoading, onExportPdf, exportPdfBusy, backPanelTemplates }) {
const cat = (categories || []).find(c => c.id === p.category);
const showMachineVariant = isUpsCategory(cat) && p.machineVariant;
const L = (key) => (exportLabels && exportLabels[key]) || key;
const V = (value) => (containsChinese(value) ? L(value) : value);
const langInfo = EXPORT_LANGUAGES.find(l => l.code === exportLang) || EXPORT_LANGUAGES[0];
const exportRows = productExportRows(p, categories);
const backPanelTemplate = (backPanelTemplates || []).find(t => t.id === p.backPanelTemplateId);
return (
<>
<div className="panel section-gap">
@ -5184,6 +5559,12 @@ function ProductDetail({ product: p, onEdit, onDelete, categories, exportLang, o
<Info icon={<Zap size={13} />} label="电压" value={p.voltage || '—'} />
<Info icon={<Tag size={13} />} label="正本价格(元)" value={p.costPrice || '—'} />
</div>
{backPanelTemplate && (
<div className="field">
<span className="field-label">后板图{backPanelTemplate.name}</span>
<BackPanelReadOnly template={backPanelTemplate} />
</div>
)}
{p.notes && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{p.notes}</div></div>}
</div>
</div>