Files
2026-08-08 15:53:53 +08:00

496 lines
21 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 可视化页面编辑器(可复用):三栏布局(图标栏 | 编辑画布 | 标签页右栏)
* 右栏:预览(只读实时模拟)/ 属性 / 素材(可删除)
* 支持元素类型: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 = '<div class="sp-row"><span>参数 1</span><span>值 1</span></div><div class="sp-row"><span>参数 2</span><span>值 2</span></div>';
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 = '<label>对齐</label><div class="pb-align">';
html += '<button type="button" class="pb-ab" data-a="left">左</button>';
html += '<button type="button" class="pb-ab" data-a="center">中</button>';
html += '<button type="button" class="pb-ab" data-a="right">右</button>';
html += '<button type="button" class="pb-ab" data-a="v">竖</button>';
html += '</div>';
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 =
'<label>文字内容</label><textarea id="pp_text" rows="3"></textarea>' +
'<label>字号 (px)</label><input type="number" id="pp_size" min="10" max="160">' +
'<label>颜色</label><input type="color" id="pp_color">' +
'<label style="display:flex;align-items:center;gap:6px"><input type="checkbox" id="pp_bold"> 加粗</label>' +
alignGroup(el) +
'<button class="pb-del" id="pp_del" type="button">删除该元素</button>';
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 =
'<label>图片宽度 (px)</label><input type="number" id="pp_w" min="40" max="3000">' +
alignGroup(el) +
'<label>替换图片(点击素材)</label><div class="pb-lib" id="pp_lib"></div>' +
'<button class="pb-del" id="pp_del" type="button">删除该元素</button>';
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 =
'<label>按钮文字</label><input type="text" id="pp_text">' +
'<label>链接地址</label><input type="text" id="pp_href" placeholder="如 /contact 或 https://...">' +
'<label>背景色</label><input type="color" id="pp_bg">' +
'<label>文字颜色</label><input type="color" id="pp_tc">' +
'<label>尺寸</label><select id="pp_size"><option value="sm">小</option><option value="md">中</option><option value="lg">大</option></select>' +
'<button class="pb-del" id="pp_del" type="button">删除该元素</button>';
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 =
'<p class="pb-empty" style="margin-bottom:10px">自动显示本产品「价格」,无需填写。</p>' +
'<label>价格文字颜色</label><input type="color" id="pp_color">' +
'<button class="pb-del" id="pp_del" type="button">删除该元素</button>';
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 = '<p class="pb-empty">自动链接到本产品的「立即购买」下单页。</p><button class="pb-del" id="pp_del" type="button">删除该元素</button>';
} else if (el.type === 'specs') {
body.innerHTML = '<p class="pb-empty">自动显示本产品的「规格参数」。</p><button class="pb-del" id="pp_del" type="button">删除该元素</button>';
}
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 = '<span style="font-size:12px;color:#94a3b8">加载中…</span>';
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 = '<span style="font-size:12px;color:#94a3b8">暂无素材,先上传</span>'; 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 = '<span style="font-size:12px;color:#dc2626">加载失败</span>'; });
}
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);
})();