Update App.jsx via upload script - 2026-08-07 17:53:15
This commit is contained in:
parent
f655d7cfd7
commit
8ef989ade4
391
App.jsx
391
App.jsx
|
|
@ -248,7 +248,7 @@ const COUNTRIES = [
|
||||||
|
|
||||||
const COUNTRY_BY_NAME = Object.fromEntries(COUNTRIES.map(c => [c.name, c]));
|
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 = () => ({
|
const blankCustomer = () => ({
|
||||||
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
id: 'c_' + Date.now() + '_' + Math.random().toString(36).slice(2, 7),
|
||||||
|
|
@ -278,6 +278,135 @@ const EXPORT_LANGUAGES = [
|
||||||
// 多语言PDF导出用的内置专业翻译词典:固定字段名 + 已知的品类/机型名称,人工核对过的翻译,
|
// 多语言PDF导出用的内置专业翻译词典:固定字段名 + 已知的品类/机型名称,人工核对过的翻译,
|
||||||
// 不依赖AI、不占用DeepSeek额度、永远一致准确。词典里没有的词(比如用户自己新建的品类/品牌名、
|
// 不依赖AI、不占用DeepSeek额度、永远一致准确。词典里没有的词(比如用户自己新建的品类/品牌名、
|
||||||
// 或者产品备注这种自由文本)才会走AI翻译并缓存(见 resolveExportTranslations)。
|
// 或者产品备注这种自由文本)才会走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 (<>
|
||||||
|
<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 = {
|
const TRANSLATION_DICTIONARY = {
|
||||||
// 文档通用
|
// 文档通用
|
||||||
'产品规格书': { en: 'Specifications', fr: 'Spécifications', ru: 'Технические характеристики', es: 'Especificaciones', ar: 'المواصفات' },
|
'产品规格书': { en: 'Specifications', fr: 'Spécifications', ru: 'Технические характеристики', es: 'Especificaciones', ar: 'المواصفات' },
|
||||||
|
|
@ -802,6 +931,7 @@ const blankProduct = () => ({
|
||||||
applicationScenarios: '', // 应用场景文本,如 "电脑、投影仪、打印机、摄像机、收银机"
|
applicationScenarios: '', // 应用场景文本,如 "电脑、投影仪、打印机、摄像机、收银机"
|
||||||
container20ft: '', container40ft: '', container40hq: '', // 装箱数量(20尺柜/40尺柜/40高柜,无栈板台数)
|
container20ft: '', container40ft: '', container40hq: '', // 装箱数量(20尺柜/40尺柜/40高柜,无栈板台数)
|
||||||
extraSpecs: [], // 动态兜底参数:AI识别到但不在上面任何具名字段里的参数,原样保留 {label, value},如 [{label:'浮充电压', value:'13.7VDC/节'}]
|
extraSpecs: [], // 动态兜底参数:AI识别到但不在上面任何具名字段里的参数,原样保留 {label, value},如 [{label:'浮充电压', value:'13.7VDC/节'}]
|
||||||
|
backPanelTemplateId: '', // 引用的后板图模板id(可跨产品共用同一个模板),迷你UPS不适用
|
||||||
productImageFileName: '', productImageOriginalName: '', // 产品主图,导出多语言PDF时展示
|
productImageFileName: '', productImageOriginalName: '', // 产品主图,导出多语言PDF时展示
|
||||||
sellingPoints: '', // 对客卖点/主要特点,每行一条,专门给客户看的(和下面的costPrice/notes内部信息彻底分开,不会一起导出)
|
sellingPoints: '', // 对客卖点/主要特点,每行一条,专门给客户看的(和下面的costPrice/notes内部信息彻底分开,不会一起导出)
|
||||||
costPrice: '', notes: '',
|
costPrice: '', notes: '',
|
||||||
|
|
@ -1322,7 +1452,12 @@ async function loadAll() {
|
||||||
const jl = await storageGet(STORAGE_KEYS.jmsLines);
|
const jl = await storageGet(STORAGE_KEYS.jmsLines);
|
||||||
if (jl && jl.value) jmsLines = JSON.parse(jl.value);
|
if (jl && jl.value) jmsLines = JSON.parse(jl.value);
|
||||||
} catch (e) {}
|
} 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 -------------------------------- */
|
/* -------------------------------- styles -------------------------------- */
|
||||||
|
|
@ -1816,6 +1951,7 @@ const NAV_GROUPS = [
|
||||||
{ label: '产品', items: [
|
{ label: '产品', items: [
|
||||||
{ id: 'groups', label: '产品分组', icon: FolderTree },
|
{ id: 'groups', label: '产品分组', icon: FolderTree },
|
||||||
{ id: 'products', label: '产品管理', icon: Package },
|
{ id: 'products', label: '产品管理', icon: Package },
|
||||||
|
{ id: 'backpanel', label: '后板图模板', icon: Settings2 },
|
||||||
] },
|
] },
|
||||||
];
|
];
|
||||||
const STANDALONE_NAV = [
|
const STANDALONE_NAV = [
|
||||||
|
|
@ -1997,6 +2133,7 @@ function MainApp({ username, onLogout }) {
|
||||||
const [catalogEditDraft, setCatalogEditDraft] = useState({ title: '', category: '', brand: '', type: '' });
|
const [catalogEditDraft, setCatalogEditDraft] = useState({ title: '', category: '', brand: '', type: '' });
|
||||||
const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all');
|
const [catalogCategoryFilter, setCatalogCategoryFilter] = useState('all');
|
||||||
const [jmsLines, setJmsLines] = useState([]);
|
const [jmsLines, setJmsLines] = useState([]);
|
||||||
|
const [backPanelTemplates, setBackPanelTemplates] = useState([]);
|
||||||
const [telegramConfigHasToken, setTelegramConfigHasToken] = useState(false);
|
const [telegramConfigHasToken, setTelegramConfigHasToken] = useState(false);
|
||||||
const [telegramConfigChatId, setTelegramConfigChatId] = useState('');
|
const [telegramConfigChatId, setTelegramConfigChatId] = useState('');
|
||||||
|
|
||||||
|
|
@ -2023,6 +2160,7 @@ function MainApp({ username, onLogout }) {
|
||||||
setPackingLists(data.packingLists);
|
setPackingLists(data.packingLists);
|
||||||
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
|
setCatalogDocs((data.catalogDocs || []).filter(d => d.fileName));
|
||||||
setJmsLines(data.jmsLines || []);
|
setJmsLines(data.jmsLines || []);
|
||||||
|
setBackPanelTemplates(data.backPanelTemplates || []);
|
||||||
setLoaded(true);
|
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.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.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.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); }
|
function notify(msg) { setToast(msg); setTimeout(() => setToast(''), 2200); }
|
||||||
|
|
||||||
|
|
@ -3311,7 +3450,7 @@ function MainApp({ username, onLogout }) {
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{productDraft ? (
|
{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 ? (
|
) : selectedProduct ? (
|
||||||
<ProductDetail
|
<ProductDetail
|
||||||
product={selectedProduct} onEdit={() => startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)}
|
product={selectedProduct} onEdit={() => startEditProduct(selectedProduct)} onDelete={() => deleteProduct(selectedProduct.id)}
|
||||||
|
|
@ -3319,6 +3458,7 @@ function MainApp({ username, onLogout }) {
|
||||||
exportLang={productExportLang} onExportLangChange={setProductExportLang}
|
exportLang={productExportLang} onExportLangChange={setProductExportLang}
|
||||||
exportLabels={productExportLabels} exportLoading={productExportLoading}
|
exportLabels={productExportLabels} exportLoading={productExportLoading}
|
||||||
onExportPdf={handleExportProductPdf} exportPdfBusy={productExportPdfBusy}
|
onExportPdf={handleExportProductPdf} exportPdfBusy={productExportPdfBusy}
|
||||||
|
backPanelTemplates={backPanelTemplates}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="panel"><div className="empty-state"><Package size={28} />选择左侧产品查看详情,或新建一个产品档案</div></div>
|
<div className="panel"><div className="empty-state"><Package size={28} />选择左侧产品查看详情,或新建一个产品档案</div></div>
|
||||||
|
|
@ -3327,6 +3467,10 @@ function MainApp({ username, onLogout }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'backpanel' && (
|
||||||
|
<BackPanelStudio templates={backPanelTemplates} setTemplates={setBackPanelTemplates} onNotify={notify} />
|
||||||
|
)}
|
||||||
|
|
||||||
{view === 'catalog' && (
|
{view === 'catalog' && (
|
||||||
<div
|
<div
|
||||||
className={`catalog-dropzone ${catalogDragActive ? 'drag-active' : ''}`}
|
className={`catalog-dropzone ${catalogDragActive ? 'drag-active' : ''}`}
|
||||||
|
|
@ -4614,7 +4758,227 @@ function JmsPage({ lines, onSave, onDelete, onCheck, onNotify }) {
|
||||||
|
|
||||||
/* ----------------------------- product form / detail ----------------------------- */
|
/* ----------------------------- 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 set = (patch) => setDraft(d => ({ ...d, ...patch }));
|
||||||
const cat = (categories || []).find(c => c.id === draft.category);
|
const cat = (categories || []).find(c => c.id === draft.category);
|
||||||
const brandOptions = cat ? cat.brands : [];
|
const brandOptions = cat ? cat.brands : [];
|
||||||
|
|
@ -5068,6 +5432,16 @@ function ProductForm({ draft, setDraft, onSave, onCancel, categories, onManage,
|
||||||
</div>
|
</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">
|
||||||
<div className="field-label-row">
|
<div className="field-label-row">
|
||||||
<span className="field-label">产品主图(可选,用于多语言PDF导出)</span>
|
<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 cat = (categories || []).find(c => c.id === p.category);
|
||||||
const showMachineVariant = isUpsCategory(cat) && p.machineVariant;
|
const showMachineVariant = isUpsCategory(cat) && p.machineVariant;
|
||||||
const L = (key) => (exportLabels && exportLabels[key]) || key;
|
const L = (key) => (exportLabels && exportLabels[key]) || key;
|
||||||
const V = (value) => (containsChinese(value) ? L(value) : value);
|
const V = (value) => (containsChinese(value) ? L(value) : value);
|
||||||
const langInfo = EXPORT_LANGUAGES.find(l => l.code === exportLang) || EXPORT_LANGUAGES[0];
|
const langInfo = EXPORT_LANGUAGES.find(l => l.code === exportLang) || EXPORT_LANGUAGES[0];
|
||||||
const exportRows = productExportRows(p, categories);
|
const exportRows = productExportRows(p, categories);
|
||||||
|
const backPanelTemplate = (backPanelTemplates || []).find(t => t.id === p.backPanelTemplateId);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="panel section-gap">
|
<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={<Zap size={13} />} label="电压" value={p.voltage || '—'} />
|
||||||
<Info icon={<Tag size={13} />} label="正本价格(元)" value={p.costPrice || '—'} />
|
<Info icon={<Tag size={13} />} label="正本价格(元)" value={p.costPrice || '—'} />
|
||||||
</div>
|
</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>}
|
{p.notes && <div className="field"><span className="field-label">备注</span><div style={{ fontSize: 12.5, whiteSpace: 'pre-wrap' }}>{p.notes}</div></div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue