/* 可视化页面编辑器(可复用):三栏布局(图标栏 | 编辑画布 | 标签页右栏) * 右栏:预览(只读实时模拟)/ 属性 / 素材(可删除) * 支持元素类型:text 文字, image 图片, button 链接按钮, buy 产品购买, price 产品价格, specs 产品规格 * 对齐:左/中/右 + 竖(文字=竖排 writing-mode,图片=垂直居中) * 依赖全局(由后台 partial 注入): * __PB_INIT__ 初始布局数组 * __PB_MODULE__ page|product|news|case|category * __PB_MEDIA__ 素材列表接口 * __PB_UPLOAD__ 上传接口 * __PB_DELETE__ 删除接口前缀(拼接文件名) * __PB_CSRF__ CSRF 令牌 */ (function () { 'use strict'; var STAGE_W = 720; var stage = document.getElementById('pbStage'); if (!stage) return; var MODULE = (window.__PB_MODULE__ || 'page'); var elements = Array.isArray(window.__PB_INIT__) ? window.__PB_INIT__.slice() : []; var selectedId = null; var zCounter = elements.reduce(function (m, e) { return Math.max(m, e.z || 0); }, 0); function uid() { return 'el_' + Date.now().toString(36) + Math.floor(Math.random() * 1e4).toString(36); } function nextZ() { return ++zCounter; } function findEl(id) { for (var i = 0; i < elements.length; i++) if (elements[i].id === id) return elements[i]; return null; } function nodeOf(id) { return stage.querySelector('[data-id="' + id + '"]'); } function ptOf(ev) { return ev.touches && ev.touches[0] ? ev.touches[0] : ev; } function on(id, evt, fn) { var el = document.getElementById(id); if (el) el.addEventListener(evt, fn); } /* ---------- 对齐样式(文字:text-align + 竖排;图片:flex 水平 + 垂直居中) ---------- */ function styleAlign(el, node) { node.style.textAlign = ''; node.style.writingMode = ''; node.style.display = ''; node.style.flexDirection = ''; node.style.alignItems = ''; node.style.justifyContent = ''; if (el.type === 'image') { node.style.display = 'flex'; node.style.flexDirection = 'column'; node.style.alignItems = (el.align === 'center') ? 'center' : (el.align === 'right') ? 'flex-end' : 'flex-start'; node.style.justifyContent = el.v ? 'center' : 'flex-start'; } else if (el.type === 'text') { node.style.textAlign = el.align || 'left'; if (el.v) node.style.writingMode = 'vertical-rl'; } } /* ---------- 通用指针拖拽(鼠标 + 触摸) ---------- */ function onPointer(ev, handlers) { ev.preventDefault(); var move = function (e) { if (handlers.move) handlers.move(ptOf(e)); }; var up = function () { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); document.removeEventListener('touchmove', move); document.removeEventListener('touchend', up); if (handlers.end) handlers.end(); }; document.addEventListener('mousemove', move); document.addEventListener('mouseup', up); document.addEventListener('touchmove', move, { passive: false }); document.addEventListener('touchend', up); if (handlers.start) handlers.start(ptOf(ev)); } /* ---------- 构建元素内部内容 ---------- */ function buildInner(el) { if (el.type === 'image') { var im = document.createElement('img'); im.className = 'pb-img'; im.src = el.src || ''; im.alt = ''; im.draggable = false; im.addEventListener('load', updateStageSize); return im; } if (el.type === 'button' || el.type === 'buy') { var a = document.createElement('a'); a.className = 'pb-static pb-btn-prev' + (el.type === 'buy' ? ' pb-buy' : ''); a.textContent = el.type === 'buy' ? '立即购买' : (el.text || '按钮'); if (el.type === 'button') { if (el.bg) a.style.background = el.bg; if (el.tc) a.style.color = el.tc; a.style.fontSize = (el.size === 'lg' ? '18px' : el.size === 'sm' ? '13px' : '15px'); } return a; } if (el.type === 'price') { var d = document.createElement('div'); d.className = 'pb-static pb-price-prev'; d.textContent = '¥ — 起/套'; if (el.color) d.style.color = el.color; return d; } if (el.type === 'specs') { var t = document.createElement('div'); t.className = 'pb-static pb-specs-prev'; t.innerHTML = '
参数 1值 1
参数 2值 2
'; return t; } var tx = document.createElement('div'); tx.className = 'pb-text'; tx.textContent = el.text != null ? el.text : ''; tx.style.fontSize = (el.fontSize || 18) + 'px'; tx.style.color = el.color || '#0f172a'; tx.style.fontWeight = el.bold ? '700' : '400'; tx.style.textAlign = el.align || 'left'; tx.style.lineHeight = '1.4'; return tx; } /* ---------- 渲染单个元素 ---------- */ function renderEl(el) { var wrap = document.createElement('div'); wrap.className = 'pb-el'; wrap.setAttribute('data-id', el.id); wrap.style.left = (el.x || 0) + 'px'; wrap.style.top = (el.y || 0) + 'px'; wrap.style.width = (el.w || 200) + 'px'; wrap.style.zIndex = el.z || 1; wrap.appendChild(buildInner(el)); styleAlign(el, wrap); var h = document.createElement('span'); h.className = 'pb-handle'; h.addEventListener('mousedown', function (e) { startResize(e, el); }); h.addEventListener('touchstart', function (e) { startResize(e, el); }, { passive: false }); wrap.appendChild(h); wrap.addEventListener('mousedown', function (e) { startDrag(e, el); }); wrap.addEventListener('touchstart', function (e) { startDrag(e, el); }, { passive: false }); wrap.addEventListener('click', function (e) { e.stopPropagation(); selectEl(el.id); }); if (el.type === 'text') { wrap.addEventListener('dblclick', function (e) { e.stopPropagation(); startEdit(el, wrap.firstChild); }); } return wrap; } function renderAll() { stage.innerHTML = ''; elements.forEach(function (el) { stage.appendChild(renderEl(el)); }); if (selectedId && nodeOf(selectedId)) nodeOf(selectedId).classList.add('sel'); updateStageSize(); renderPreview(); } /* 画布高度自适应:容纳所有元素底部 */ function updateStageSize() { var maxB = 480; elements.forEach(function (el) { var n = nodeOf(el.id); if (!n) return; var b = (el.y || 0) + n.offsetHeight; if (b > maxB) maxB = b; }); stage.style.minHeight = maxB + 'px'; } function applyEl(el) { var n = nodeOf(el.id); if (!n) return; n.style.width = (el.w || 200) + 'px'; styleAlign(el, n); var inner = n.firstChild; if (el.type === 'image') { inner.src = el.src || ''; } else if (el.type === 'text') { inner.textContent = el.text != null ? el.text : ''; inner.style.fontSize = (el.fontSize || 18) + 'px'; inner.style.color = el.color || '#0f172a'; inner.style.fontWeight = el.bold ? '700' : '400'; inner.style.textAlign = el.align || 'left'; inner.style.writingMode = el.v ? 'vertical-rl' : ''; } else if (el.type === 'button') { inner.textContent = el.text || '按钮'; if (el.bg) inner.style.background = el.bg; if (el.tc) inner.style.color = el.tc; inner.style.fontSize = (el.size === 'lg' ? '18px' : el.size === 'sm' ? '13px' : '15px'); } else if (el.type === 'price') { if (el.color) inner.style.color = el.color; } updateStageSize(); renderPreview(); } /* ---------- 拖拽移动 ---------- */ function startDrag(e, el) { if (e.target && e.target.classList.contains('pb-handle')) return; e.stopPropagation(); selectEl(el.id); var p = ptOf(e); var sx = p.clientX, sy = p.clientY, ox = el.x || 0, oy = el.y || 0; onPointer(e, { move: function (p2) { el.x = Math.max(0, ox + (p2.clientX - sx)); el.y = Math.max(0, oy + (p2.clientY - sy)); var n = nodeOf(el.id); if (n) { n.style.left = el.x + 'px'; n.style.top = el.y + 'px'; } } }); } /* ---------- 缩放(右下角) ---------- */ function startResize(e, el) { e.stopPropagation(); e.preventDefault(); selectEl(el.id); var p = ptOf(e); var sx = p.clientX, sw = el.w || 200; onPointer(e, { move: function (p2) { el.w = Math.max(40, sw + (p2.clientX - sx)); var n = nodeOf(el.id); if (n) n.style.width = el.w + 'px'; } }); } /* ---------- 双击编辑文字 ---------- */ function startEdit(el, node) { node.setAttribute('contenteditable', 'true'); node.focus(); try { document.getSelection().selectAllChildren(node); } catch (x) {} function done() { node.removeAttribute('contenteditable'); el.text = node.textContent; node.removeEventListener('blur', done); if (selectedId === el.id) { syncProps(); renderPreview(); } } node.addEventListener('blur', done); } /* ---------- 选中 / 属性面板 ---------- */ function selectEl(id) { selectedId = id; Array.prototype.forEach.call(stage.querySelectorAll('.pb-el'), function (n) { n.classList.toggle('sel', n.getAttribute('data-id') === id); }); renderProps(findEl(id)); if (id) switchTab('props'); } function alignGroup(el, types) { var html = '
'; html += ''; html += ''; html += ''; html += ''; html += '
'; return html; } function wireAlign(el) { document.querySelectorAll('#pbPropsBody .pb-ab').forEach(function (b) { var a = b.getAttribute('data-a'); var on = (a === 'v') ? !!el.v : ((el.align || 'left') === a); b.classList.toggle('active', on); b.onclick = function () { if (a === 'v') el.v = !el.v; else el.align = a; wireAlign(el); applyEl(el); renderPreview(); }; }); } function renderProps(el) { var body = document.getElementById('pbPropsBody'); if (!el) { body.className = 'pb-empty'; body.textContent = '在画布中选择一个元素以编辑属性'; return; } body.className = ''; if (el.type === 'text') { body.innerHTML = '' + '' + '' + '' + alignGroup(el) + ''; document.getElementById('pp_text').value = el.text != null ? el.text : ''; document.getElementById('pp_size').value = el.fontSize || 18; document.getElementById('pp_color').value = el.color || '#0f172a'; document.getElementById('pp_bold').checked = !!el.bold; bind('pp_text', 'input', function (v) { el.text = v; applyEl(el); }); bind('pp_size', 'input', function (v) { el.fontSize = parseInt(v, 10) || 18; applyEl(el); }); bind('pp_color', 'input', function (v) { el.color = v; applyEl(el); }); bind('pp_bold', 'change', function (v) { el.bold = !!v; applyEl(el); }); wireAlign(el); } else if (el.type === 'image') { body.innerHTML = '' + alignGroup(el) + '
' + ''; document.getElementById('pp_w').value = el.w || 200; bind('pp_w', 'input', function (v) { el.w = Math.max(40, parseInt(v, 10) || 200); applyEl(el); }); fillLib(document.getElementById('pp_lib'), function (url) { el.src = url; applyEl(el); }); wireAlign(el); } else if (el.type === 'button') { body.innerHTML = '' + '' + '' + '' + '' + ''; document.getElementById('pp_text').value = el.text || '按钮'; document.getElementById('pp_href').value = el.href || ''; document.getElementById('pp_bg').value = el.bg || '#0ea5e9'; document.getElementById('pp_tc').value = el.tc || '#ffffff'; document.getElementById('pp_size').value = el.size || 'md'; bind('pp_text', 'input', function (v) { el.text = v; applyEl(el); }); bind('pp_href', 'input', function (v) { el.href = v; }); bind('pp_bg', 'input', function (v) { el.bg = v; applyEl(el); }); bind('pp_tc', 'input', function (v) { el.tc = v; applyEl(el); }); bind('pp_size', 'change', function (v) { el.size = v; applyEl(el); }); } else if (el.type === 'price') { body.innerHTML = '

自动显示本产品「价格」,无需填写。

' + '' + ''; document.getElementById('pp_color').value = el.color || '#0f172a'; bind('pp_color', 'input', function (v) { el.color = v; applyEl(el); }); } else if (el.type === 'buy') { body.innerHTML = '

自动链接到本产品的「立即购买」下单页。

'; } else if (el.type === 'specs') { body.innerHTML = '

自动显示本产品的「规格参数」。

'; } var del = document.getElementById('pp_del'); if (del) del.addEventListener('click', function () { removeEl(el.id); }); } function bind(id, evt, fn) { var el = document.getElementById(id); if (!el) return; el.addEventListener(evt, function () { fn(el.value !== undefined ? el.value : el.checked); }); } function syncProps() { var el = findEl(selectedId); if (el) renderProps(el); } function removeEl(id) { elements = elements.filter(function (e) { return e.id !== id; }); if (selectedId === id) selectedId = null; renderAll(); switchTab('preview'); if (!selectedId) renderProps(null); } /* ---------- 标签页切换 ---------- */ function switchTab(name) { document.querySelectorAll('.pb-tab').forEach(function (t) { t.classList.toggle('active', t.getAttribute('data-tab') === name); }); var panes = { preview: 'panePreview', props: 'paneProps', material: 'paneMaterial' }; Object.keys(panes).forEach(function (k) { document.getElementById(panes[k]).classList.toggle('hidden', k !== name); }); if (name === 'preview') fitPreview(); } document.querySelectorAll('.pb-tab').forEach(function (t) { t.addEventListener('click', function () { switchTab(t.getAttribute('data-tab')); }); }); /* ---------- 实时预览(克隆只读 + 自适应缩放) ---------- */ function renderPreview() { var pv = document.getElementById('pbPreview'); if (!pv) return; pv.innerHTML = stage.innerHTML; pv.querySelectorAll('.pb-handle').forEach(function (h) { h.remove(); }); pv.querySelectorAll('.pb-el').forEach(function (n) { n.classList.remove('sel'); n.style.outline = ''; }); pv.querySelectorAll('[contenteditable]').forEach(function (n) { n.removeAttribute('contenteditable'); }); fitPreview(); } function fitPreview() { var wrap = document.getElementById('pbPreviewWrap'); var pv = document.getElementById('pbPreview'); if (!wrap || !pv) return; var avail = wrap.clientWidth || STAGE_W; var scale = Math.min(1, avail / STAGE_W); var m = 420; pv.querySelectorAll('.pb-el').forEach(function (n) { var y = parseInt(n.style.top, 10) || 0; var b = y + n.offsetHeight; if (b > m) m = b; }); pv.style.transform = 'scale(' + scale + ')'; pv.style.transformOrigin = 'top left'; pv.style.height = m + 'px'; wrap.style.height = (m * scale) + 'px'; } /* ---------- 素材库(含删除) ---------- */ function fillLib(container, onPick) { if (!container) return; container.innerHTML = '加载中…'; fetch(window.__PB_MEDIA__) .then(function (r) { return r.json(); }) .then(function (j) { container.innerHTML = ''; var items = (j && j.items) || []; if (!items.length) { container.innerHTML = '暂无素材,先上传'; return; } items.forEach(function (it) { var cell = document.createElement('div'); cell.className = 'pb-lib-item'; var img = document.createElement('img'); img.src = it.url; img.title = it.name; img.addEventListener('click', function () { onPick(it.url); }); var del = document.createElement('button'); del.className = 'pb-lib-del'; del.type = 'button'; del.textContent = '×'; del.title = '删除素材'; del.addEventListener('click', function (e) { e.stopPropagation(); deleteMedia(it.name); }); cell.appendChild(img); cell.appendChild(del); container.appendChild(cell); }); }) .catch(function () { container.innerHTML = '加载失败'; }); } function deleteMedia(name) { if (!confirm('确定删除素材「' + name + '」?此操作不可撤销。')) return; fetch(window.__PB_DELETE__ + encodeURIComponent(name), { method: 'POST', headers: { 'X-CSRF-TOKEN': window.__PB_CSRF__ } }) .then(function (r) { return r.json(); }) .then(function (j) { if (j && j.ok) { loadLib(); var pp = document.getElementById('pp_lib'); if (pp) fillLib(pp, function (url) { var el = findEl(selectedId); if (el) { el.src = url; applyEl(el); } }); } else { alert((j && j.msg) || '删除失败'); } }) .catch(function () { alert('删除失败,请重试'); }); } function loadLib() { fillLib(document.getElementById('pbLib'), function (url) { addImage(url); }); } /* ---------- 添加元素 ---------- */ function addText() { var el = { id: uid(), type: 'text', x: 40, y: 40, w: 320, text: '双击编辑文字', fontSize: 20, color: '#0f172a', bold: false, align: 'left', v: false, z: nextZ() }; elements.push(el); renderAll(); selectEl(el.id); } function addImage(url) { var el = { id: uid(), type: 'image', x: 40, y: 40, w: 280, src: url, align: 'left', v: false, z: nextZ() }; elements.push(el); renderAll(); selectEl(el.id); } function addButton() { var el = { id: uid(), type: 'button', x: 40, y: 40, w: 200, text: '按钮', href: '#', bg: '#0ea5e9', tc: '#ffffff', size: 'md', z: nextZ() }; elements.push(el); renderAll(); selectEl(el.id); } function addBuy() { var el = { id: uid(), type: 'buy', x: 40, y: 40, w: 200, z: nextZ() }; elements.push(el); renderAll(); selectEl(el.id); } function addPrice() { var el = { id: uid(), type: 'price', x: 40, y: 40, w: 240, color: '#0f172a', z: nextZ() }; elements.push(el); renderAll(); selectEl(el.id); } function addSpecs() { var el = { id: uid(), type: 'specs', x: 40, y: 40, w: 360, z: nextZ() }; elements.push(el); renderAll(); selectEl(el.id); } /* 图标栏动作 */ document.querySelectorAll('.pb-rail-btn').forEach(function (b) { b.addEventListener('click', function () { var act = b.getAttribute('data-act'); if (act === 'material') { switchTab('material'); loadLib(); } else if (act === 'image') { switchTab('material'); } else if (act === 'text') { addText(); } else if (act === 'button') { addButton(); } else if (act === 'buy') { addBuy(); } else if (act === 'price') { addPrice(); } else if (act === 'specs') { addSpecs(); } }); }); stage.addEventListener('click', function () { selectEl(null); }); /* ---------- 上传素材 ---------- */ on('pbFile', 'change', function () { var file = this.files && this.files[0]; if (!file) return; var fd = new FormData(); fd.append('file', file); fetch(window.__PB_UPLOAD__, { method: 'POST', body: fd, headers: { 'X-CSRF-TOKEN': window.__PB_CSRF__ } }) .then(function (r) { return r.json(); }) .then(function (j) { if (j && j.ok) { addImage(j.url); loadLib(); } else alert((j && j.msg) || '上传失败'); }) .catch(function () { alert('上传失败,请重试'); }); this.value = ''; }); /* ---------- 保存:序列化到隐藏字段 ---------- */ on('pbForm', 'submit', function () { var f = document.getElementById('pbLayout'); if (f) f.value = JSON.stringify(elements); }); /* ---------- 启动 ---------- */ renderAll(); loadLib(); window.addEventListener('resize', fitPreview); })();