diff --git a/App.jsx b/App.jsx
index 2f2037c..3e1eed5 100644
--- a/App.jsx
+++ b/App.jsx
@@ -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导出用的内置专业翻译词典:固定字段名 + 已知的品类/机型名称,人工核对过的翻译,
// 不依赖AI、不占用DeepSeek额度、永远一致准确。词典里没有的词(比如用户自己新建的品类/品牌名、
// 或者产品备注这种自由文本)才会走AI翻译并缓存(见 resolveExportTranslations)。
+/* ---------------------------- 后板图图标库 ---------------------------- */
+// 后板图编辑器用到的固定图标集:基础元件(交流输入口/保险丝/输出插座/USB/RJ45/风扇)
+// + 常见插座类型(可选配插座变体)。每个图标是一段简约线条风格的SVG,viewBox统一
+// 0 0 40 40,用currentColor描边,方便跟着容器的文字颜色走、也方便被选中时变色高亮。
+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 (<>
+
+
+
+
+ >);
+ case 'fuse_breaker':
+ return (<>
+
+
+
+ >);
+ case 'output_generic':
+ return (<>
+
+
+
+ >);
+ case 'usb':
+ return (<>
+
+
+
+ >);
+ case 'rj45':
+ return (<>
+
+
+ {[0, 1, 2, 3, 4, 5].map(i => ())}
+ >);
+ case 'fan':
+ return (<>
+
+ {[0, 90, 180, 270].map(a => (
+
+ ))}
+
+ >);
+ case 'socket_cn':
+ return (<>
+
+
+
+
+ >);
+ case 'socket_schuko':
+ return (<>
+
+
+
+
+
+ >);
+ case 'socket_uk':
+ return (<>
+
+
+
+
+ >);
+ case 'socket_us':
+ return (<>
+
+
+
+
+ >);
+ case 'socket_au':
+ return (<>
+
+
+
+
+ >);
+ case 'socket_za':
+ return (<>
+
+
+
+
+ >);
+ case 'socket_it':
+ return (<>
+
+
+
+
+ >);
+ case 'socket_iec':
+ return (<>
+
+
+
+
+ >);
+ default:
+ return ;
+ }
+ })();
+ return ;
+}
+
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: '', // 引用的后板图模板id(可跨产品共用同一个模板),迷你UPS不适用
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 }) {
{productDraft ? (
-
setTaxManageMode(mode)} onNotify={notify} />
+ setTaxManageMode(mode)} onNotify={notify} backPanelTemplates={backPanelTemplates} />
) : selectedProduct ? (
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}
/>
) : (
@@ -3327,6 +3467,10 @@ function MainApp({ username, onLogout }) {
)}
+ {view === 'backpanel' && (
+
+ )}
+
{view === 'catalog' && (
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 (
+
+
+
后板图模板
+
+
+
+ {(!templates || templates.length === 0) ? (
+
还没有后板图模板,点击右上角新建一个
+ ) : templates.map(t => (
+
{ setEditingId(t.id); setSelectedElId(null); }}>
+
+
+
{t.name || '(未命名模板)'}
+
{(t.elements || []).length} 个元素
+
+
+
+ ))}
+
+
+ );
+ }
+
+ // ---------- 编辑画布视图 ----------
+ return (
+
+
+
updateEditing({ name: e.target.value })}
+ />
+
+
+
+
+
+
+
拖拽图标到右侧画布
+ {['基础元件', '可选配插座'].map(group => (
+
+
{group}
+
+ {BACK_PANEL_ICON_LIBRARY.filter(i => i.group === group).map(icon => (
+
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)',
+ }}
+ >
+
+ {icon.label}
+
+ ))}
+
+
+ ))}
+
+
+
+
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 => (
+
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,
+ }}
+ >
+
+
+ ))}
+ {(editing.elements || []).length === 0 && (
+
+ 这里代表机身后板轮廓,把左边的图标拖进来开始摆放
+
+ )}
+
+
+ {selectedEl && (
+
+ {BACK_PANEL_ICON_LABELS[selectedEl.type] || selectedEl.type}
+
+
+
+
+
+ )}
+
+
+
+ );
+}
+
+// 只读渲染(产品详情页/PDF导出用):同一套图标定义,不带交互,纯展示
+function BackPanelReadOnly({ template }) {
+ if (!template) return null;
+ return (
+
+ {(template.elements || []).map(el => (
+
+
+
+ ))}
+
+ );
+}
+
+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,
)}
+ {isUps && !isMiniUps && (
+
+ 后板图模板(可选,跨产品共用同一个布局)
+
+
+ )}
+
产品主图(可选,用于多语言PDF导出)
@@ -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 (
<>
@@ -5184,6 +5559,12 @@ function ProductDetail({ product: p, onEdit, onDelete, categories, exportLang, o
} label="电压" value={p.voltage || '—'} />
} label="正本价格(元)" value={p.costPrice || '—'} />
+ {backPanelTemplate && (
+
+ 后板图({backPanelTemplate.name})
+
+
+ )}
{p.notes &&
}