Files
workspace/code/fms/module-designer-demo.html
T
2026-08-12 22:12:01 +08:00

506 lines
34 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>模块设计器 Demo v5</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #1e293b; }
.card { background: #fff; border-radius: 10px; border: 1px solid #e5e7eb; }
.toggle { width: 34px; height: 20px; border-radius: 10px; background: #d1d5db; cursor: pointer; position: relative; transition: all .15s; flex-shrink: 0; pointer-events: auto; }
.toggle.on { background: #3b82f6; }
.toggle::after { content:''; width:16px; height:16px; border-radius:50%; background:#fff; position:absolute; top:2px; left:2px; transition:left .15s; box-shadow: 0 1px 3px rgba(0,0,0,.15); }
.toggle.on::after { left: 16px; }
.cfg-row { cursor: pointer; border-left: 3px solid transparent; transition: background .1s; }
.cfg-row:hover { background: #f8fafc; }
.cfg-row.active { background: #eff6ff; border-left-color: #3b82f6; }
.cfg-row .quick-edit { max-height: 0; overflow: hidden; transition: max-height .25s ease; }
.cfg-row.active .quick-edit { max-height: 320px; }
.cfg-row.drag-over { border-top: 3px solid #3b82f6; }
.drag-handle { cursor: grab; opacity: 0; transition: opacity .1s; }
.cfg-row:hover .drag-handle { opacity: 1; }
.drag-handle:active { cursor: grabbing; }
.preview-field { transition: all .15s; position: relative; border-radius: 4px; }
.preview-field.highlight { background: #eff6ff; }
.preview-field.highlight::before { content: ''; position: absolute; left: -8px; top: 0; bottom: 0; width: 3px; background: #3b82f6; border-radius: 2px; }
.group-block { border: 1px dashed #e2e8f0; border-radius: 8px; margin-bottom: 12px; transition: border-color .15s; }
.group-block:hover { border-color: #cbd5e1; }
.group-block.highlight-group { border-color: #3b82f6; background: #fafbff; }
.input-sm { width:100%; font-size:12px; border:1px solid #e5e7eb; border-radius:5px; padding:5px 8px; outline:none; transition: border-color .12s; }
.input-sm:focus { border-color: #3b82f6; }
.select-sm { width:100%; font-size:12px; border:1px solid #e5e7eb; border-radius:5px; padding:5px 8px; outline:none; background:#fff; transition: border-color .12s; }
.select-sm:focus { border-color: #3b82f6; }
.btn { font-size:12px; padding:5px 12px; border-radius:5px; font-weight:500; cursor:pointer; transition:all .12s; border:none; display:inline-flex; align-items:center; gap:4px; }
.btn-primary { background:#3b82f6; color:#fff; } .btn-primary:hover { background:#2563eb; }
.btn-ghost { background:transparent; color:#64748b; } .btn-ghost:hover { background:#f1f5f9; }
.btn-outline { background:#fff; color:#475569; border:1px solid #e5e7eb; } .btn-outline:hover { background:#f8fafc; }
.toast { position:fixed; bottom:24px; left:50%; transform:translateX(-50%) translateY(10px); background:#1e293b; color:#fff; font-size:12px; padding:8px 18px; border-radius:8px; z-index:60; opacity:0; pointer-events:none; transition:all .2s; }
.toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
.ds-drawer { position:fixed; right:0; top:0; bottom:0; width:400px; background:#fff; box-shadow:-4px 0 24px rgba(0,0,0,.1); z-index:50; transform:translateX(100%); transition:transform .22s ease; }
.ds-drawer.open { transform:translateX(0); }
.ds-overlay { position:fixed; inset:0; background:rgba(15,23,42,.2); z-index:49; opacity:0; pointer-events:none; transition:opacity .22s; }
.ds-overlay.open { opacity:1; pointer-events:auto; }
</style>
</head>
<body class="min-h-screen flex flex-col">
<div id="stage" class="flex-1 flex flex-col max-w-[1200px] mx-auto w-full p-5 gap-4">
<!-- ===== MODULE SETTINGS (always open) ===== -->
<div class="card p-5">
<h3 class="text-sm font-semibold text-slate-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
模块设置
</h3>
<div class="grid grid-cols-4 gap-x-5 gap-y-3">
<div><label class="text-[11px] text-slate-400 block mb-1">编码</label><input class="input-sm bg-slate-50 text-slate-500" value="d_sea" readonly></div>
<div><label class="text-[11px] text-slate-400 block mb-1">名称</label><input class="input-sm" value="海运主单" id="moduleNameInput"></div>
<div><label class="text-[11px] text-slate-400 block mb-1">类型</label>
<select class="select-sm" id="moduleTypeSelect">
<option value="data" selected>data — 数据模块</option>
<option value="directory">directory — 目录</option>
<option value="page">page — 页面</option>
</select>
</div>
<div><label class="text-[11px] text-slate-400 block mb-1">数据视图</label>
<select class="select-sm"><option selected>v_sea_master</option><option>v_sea_fee</option><option>v_container</option></select>
</div>
<div class="col-span-2">
<label class="text-[11px] text-slate-400 block mb-1">编辑模式</label>
<div class="flex bg-slate-100 rounded-md p-0.5" id="editModeSwitcher">
<button class="editmode-btn flex-1 text-[11px] py-1.5 rounded font-medium transition-all bg-white shadow-sm text-blue-600" data-mode="form">表单弹窗</button>
<button class="editmode-btn flex-1 text-[11px] py-1.5 rounded font-medium transition-all text-slate-500" data-mode="inline">表内行编辑</button>
<button class="editmode-btn flex-1 text-[11px] py-1.5 rounded font-medium transition-all text-slate-500" data-mode="none">只读</button>
</div>
</div>
<div class="col-span-2 flex items-end gap-4 pb-0.5">
<label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" checked class="w-3.5 h-3.5 accent-blue-500"><span class="text-xs text-slate-600">启用</span></label>
<label class="flex items-center gap-2 cursor-pointer"><input type="checkbox" checked class="w-3.5 h-3.5 accent-blue-500"><span class="text-xs text-slate-600">显示菜单</span></label>
</div>
</div>
</div>
<!-- ===== TWO-PANE ===== -->
<div class="flex gap-4 flex-1 min-h-0">
<!-- LEFT: Preview -->
<div class="flex-[6] card flex flex-col min-h-0">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-slate-100 shrink-0">
<div class="flex items-center gap-3">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wide">实时预览</span>
<div class="flex bg-slate-100 rounded p-0.5" id="previewSwitcher">
<button class="preview-btn px-3 py-1 text-[11px] rounded transition-all bg-white shadow-sm text-blue-600 font-medium" data-pv="form">表单</button>
<button class="preview-btn px-3 py-1 text-[11px] rounded transition-all text-slate-500" data-pv="table">表格</button>
</div>
</div>
<button class="btn btn-outline text-[10px] py-1 px-3" onclick="showToast('已保存')">保存</button>
</div>
<div class="flex-1 overflow-auto p-4" id="previewArea"></div>
</div>
<!-- RIGHT: Config List -->
<div class="flex-[4] card flex flex-col min-h-0">
<div class="flex items-center justify-between px-4 py-2.5 border-b border-slate-100 shrink-0">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-wide">
字段配置 <span class="text-slate-300 font-normal ml-1" id="fieldCount">12</span>
</span>
<div class="flex items-center gap-1.5">
<button class="btn btn-primary text-[10px] py-1 px-3" onclick="addField()">+ 新增</button>
<button class="btn btn-ghost text-[10px] py-1 px-2" onclick="addGroup()">+ 分组</button>
</div>
</div>
<div class="flex-1 overflow-auto" id="configList"></div>
</div>
</div>
</div>
<!-- Data Source Drawer -->
<div class="ds-overlay" id="dsOverlay" onclick="closeDsDrawer()"></div>
<div class="ds-drawer" id="dsDrawer">
<div class="flex flex-col h-full">
<div class="flex items-center justify-between px-5 py-3 border-b border-slate-100 shrink-0">
<h3 class="text-sm font-semibold text-slate-700">下拉数据源</h3>
<button onclick="closeDsDrawer()" class="text-slate-400 hover:text-slate-600 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
<div class="flex-1 overflow-auto p-5" id="dsDrawerContent"></div>
<div class="px-5 py-3 border-t border-slate-100"><button class="btn btn-primary w-full justify-center" onclick="closeDsDrawer()">完成</button></div>
</div>
</div>
<div id="toast" class="toast"></div>
<script>
// ============================== STATE ==============================
const TYPES = [
{ v:'input', lbl:'文本', icon:'Aa', c:'#3b82f6' },
{ v:'textarea', lbl:'多行', icon:'¶', c:'#6366f1' },
{ v:'number', lbl:'数字', icon:'#', c:'#10b981' },
{ v:'select', lbl:'下拉', icon:'☰', c:'#8b5cf6' },
{ v:'date', lbl:'日期', icon:'📅', c:'#f59e0b' },
{ v:'datetime', lbl:'日期时间', icon:'🕐', c:'#ef4444' },
{ v:'switch', lbl:'开关', icon:'⇄', c:'#06b6d4' },
];
let previewMode = 'form', editMode = 'form';
let activeId = null, dsDrawerId = null, nextId = 20;
let groups = [
{ id:'g1', name:'默认区域' },
{ id:'g2', name:'订舱信息' },
{ id:'g3', name:'费用信息' },
];
let fields = [
{ id:1, code:'b_id', label:'ID', type:'input', groupId:'g1', order:10, listOn:true, listW:80, listAlign:'left', editOn:false, editReq:false, editRO:true, queryOn:false, queryOp:'like', queryDef:'', ds:null },
{ id:2, code:'container_no', label:'箱号', type:'input', groupId:'g1', order:20, listOn:true, listW:140, listAlign:'left', editOn:true, editReq:true, editRO:false, queryOn:true, queryOp:'like', queryDef:'', ds:null },
{ id:3, code:'seal_no', label:'封号', type:'input', groupId:'g1', order:25, listOn:true, listW:120, listAlign:'left', editOn:false, editReq:false, editRO:false, queryOn:false, queryOp:'like', queryDef:'', ds:null },
{ id:4, code:'carrier', label:'船公司', type:'select', groupId:'g2', order:30, listOn:true, listW:160, listAlign:'left', editOn:true, editReq:true, editRO:false, queryOn:true, queryOp:'=', queryDef:'', ds:{type:'view', view:'v_carrier', vf:'b_id', lf:'b_name', ob:'b_xh ASC'} },
{ id:5, code:'vessel', label:'船名', type:'input', groupId:'g2', order:40, listOn:true, listW:140, listAlign:'left', editOn:true, editReq:false, editRO:false, queryOn:false, queryOp:'like', queryDef:'', ds:null },
{ id:6, code:'voyage', label:'航次', type:'input', groupId:'g2', order:50, listOn:true, listW:100, listAlign:'left', editOn:true, editReq:false, editRO:false, queryOn:false, queryOp:'like', queryDef:'', ds:null },
{ id:7, code:'pol', label:'起运港', type:'select', groupId:'g2', order:60, listOn:true, listW:140, listAlign:'left', editOn:true, editReq:true, editRO:false, queryOn:true, queryOp:'=', queryDef:'', ds:{type:'view', view:'v_port', vf:'b_id', lf:'b_name'} },
{ id:8, code:'pod', label:'目的港', type:'select', groupId:'g2', order:70, listOn:true, listW:140, listAlign:'left', editOn:true, editReq:true, editRO:false, queryOn:true, queryOp:'=', queryDef:'', ds:{type:'view', view:'v_port', vf:'b_id', lf:'b_name'} },
{ id:9, code:'etd', label:'预计离港', type:'date', groupId:'g2', order:80, listOn:true, listW:130, listAlign:'left', editOn:true, editReq:false, editRO:false, queryOn:true, queryOp:'between', queryDef:'', ds:null },
{ id:10,code:'freight', label:'运费', type:'number', groupId:'g3', order:90, listOn:true, listW:120, listAlign:'right', editOn:true, editReq:false, editRO:false, queryOn:false, queryOp:'>=', queryDef:'', ds:null },
{ id:11,code:'currency', label:'币种', type:'select', groupId:'g3', order:100, listOn:true, listW:100, listAlign:'left', editOn:true, editReq:true, editRO:false, queryOn:true, queryOp:'=', queryDef:'USD', ds:{type:'static', items:[{v:'USD',l:'美元'},{v:'CNY',l:'人民币'},{v:'EUR',l:'欧元'}]} },
{ id:12,code:'status', label:'状态', type:'select', groupId:'g1', order:110, listOn:true, listW:100, listAlign:'left', editOn:false, editReq:false, editRO:false, queryOn:true, queryOp:'=', queryDef:'draft', ds:{type:'static', items:[{v:'draft',l:'草稿'},{v:'confirmed',l:'已确认'},{v:'shipped',l:'已发运'}]} },
];
// ============================== DELEGATED EVENTS ==============================
// Config list: use a single delegated click handler instead of nested stopPropagation
document.getElementById('configList').addEventListener('click', function(e) {
const row = e.target.closest('.cfg-row');
if (!row) return;
// Toggle click?
const toggleEl = e.target.closest('[data-toggle]');
if (toggleEl) {
const id = parseInt(toggleEl.dataset.fid);
const key = toggleEl.dataset.toggle;
toggleField(id, key);
return;
}
// DsDrawer button?
if (e.target.closest('[data-dsbtn]')) {
openDsDrawer(parseInt(e.target.closest('[data-dsbtn]').dataset.fid));
return;
}
// Delete button?
if (e.target.closest('[data-delbtn]')) {
deleteField(parseInt(e.target.closest('[data-delbtn]').dataset.fid));
return;
}
// Input/select in quick-edit — do nothing (let it work normally)
if (e.target.closest('input, select')) return;
// Otherwise toggle active
const id = parseInt(row.dataset.fid);
activeId = activeId === id ? null : id;
renderAll();
});
// ============================== RENDER ==============================
function renderAll() {
renderPreview();
renderConfigList();
document.getElementById('fieldCount').textContent = fields.length;
}
// --- PREVIEW ---
function renderPreview() {
document.getElementById('previewArea').innerHTML = previewMode === 'form' ? renderForm() : renderTable();
}
function renderForm() {
let html = '';
groups.forEach(g => {
const gfs = fields.filter(f => f.groupId === g.id).sort((a,b) => a.order - b.order);
if (gfs.length === 0) return;
const hl = gfs.some(f => f.id === activeId);
html += `<div class="group-block ${hl ? 'highlight-group' : ''}" data-group="${g.id}">
<div class="px-3 py-1.5 bg-slate-50/80 rounded-t-md border-b border-slate-100 flex items-center gap-2 cursor-pointer" onclick="event.stopPropagation(); highlightGroup('${g.id}')">
<span class="text-[11px] font-medium text-slate-500">${g.name}</span>
<span class="text-[10px] text-slate-300">${gfs.length}字段</span>
<div class="flex-1"></div>
<button onclick="event.stopPropagation(); renameGroup('${g.id}')" class="text-[10px] text-slate-300 hover:text-slate-500">改名</button>
</div>
<div class="p-3 space-y-3">`;
gfs.forEach(f => {
const a = f.id === activeId;
html += `<div class="preview-field flex items-center gap-3 ${a?'highlight':''}" data-fid="${f.id}" onclick="event.stopPropagation(); activeId=${f.id}; renderAll()">
<label class="text-xs text-slate-600 w-20 shrink-0 text-right select-none">${f.label}</label>
${widget(f,a)}
</div>`;
});
html += `</div></div>`;
});
const ug = fields.filter(f => !groups.some(g => g.id === f.groupId));
if (ug.length) {
html += `<div class="group-block">
<div class="px-3 py-1.5 bg-amber-50/80 rounded-t-md border-b border-slate-100"><span class="text-[11px] font-medium text-slate-500">未分组</span></div>
<div class="p-3 space-y-3">${ug.map(f => `<div class="preview-field flex items-center gap-3 ${f.id===activeId?'highlight':''}" data-fid="${f.id}" onclick="event.stopPropagation(); activeId=${f.id}; renderAll()"><label class="text-xs text-slate-600 w-20 shrink-0 text-right select-none">${f.label}</label>${widget(f,f.id===activeId)}</div>`).join('')}</div>
</div>`;
}
return html || '<div class="text-center text-xs text-slate-300 mt-16">暂无字段</div>';
}
function widget(f, a) {
const ring = a ? 'ring-2 ring-blue-300' : '';
let w = '';
if (f.editRO && editMode !== 'none') w += '<span class="text-[10px] text-slate-300 mr-1">只读</span>';
if (f.editReq && editMode !== 'none') w += '<span class="text-red-400 text-[10px] mr-1">*</span>';
switch(f.type) {
case 'select': w += `<div class="flex-1 max-w-[260px]"><div class="input-sm bg-white text-slate-400 flex items-center justify-between ${ring}"><span>请选择...</span><svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg></div></div>`; break;
case 'date': w += `<div class="input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}">2024-08-12</div>`; break;
case 'datetime': w += `<div class="input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}">2024-08-12 14:30</div>`; break;
case 'number': w += `<div class="input-sm bg-white text-slate-300 flex-1 max-w-[260px] text-right ${ring}">0.00</div>`; break;
case 'switch': w += `<div class="toggle ${ring}"></div>`; break;
case 'textarea': w += `<div class="input-sm bg-white text-slate-300 flex-1 max-w-[260px] h-16 ${ring}"></div>`; break;
default: w += `<div class="input-sm bg-white text-slate-300 flex-1 max-w-[260px] ${ring}">${f.editRO && editMode!=='none' ? '只读' : ''}</div>`; break;
}
return w;
}
function renderTable() {
const vis = fields.filter(f => f.listOn).sort((a,b) => a.order - b.order);
if (!vis.length) return '<div class="text-center text-xs text-slate-300 mt-16">暂无可见列 · 请开启字段的 L 开关</div>';
const tw = vis.reduce((s,f) => s + f.listW, 0);
let h = `<div class="overflow-x-auto border border-slate-200 rounded-lg bg-white shadow-sm"><div style="min-width:${Math.max(tw,400)}px">
<div class="flex bg-slate-50 border-b border-slate-200">`;
vis.forEach((f,i) => {
const a = f.id === activeId;
h += `<div class="relative shrink-0 px-3 py-2 border-r border-slate-200 text-xs font-medium cursor-pointer select-none"
style="width:${f.listW}px; text-align:${f.listAlign}; ${a?'background:#eff6ff;color:#2563eb':'color:#475569'}"
onclick="event.stopPropagation(); activeId=${f.id}; renderAll()">
<span class="truncate">${f.label}</span>
${i<vis.length-1?`<div class="absolute right-0 top-0 bottom-0 w-1.5 cursor-col-resize hover:bg-blue-400 z-10" onmousedown="event.stopPropagation(); startColResize(event,${f.id})"></div>`:''}
</div>`;
});
h += `</div>`;
for(let r=0; r<2; r++) {
h += `<div class="flex border-b border-slate-50 text-xs text-slate-300">`;
vis.forEach(f => { h += `<div class="shrink-0 px-3 py-2 border-r border-slate-50" style="width:${f.listW}px; text-align:${f.listAlign}">${f.type==='select'?'—':f.type==='date'?'2024-08-12':f.type==='number'?'100.00':'示例'}</div>`; });
h += `</div>`;
}
return h + `</div></div><div class="mt-2 text-[11px] text-slate-400 text-center">点击列头选中 · 拖拽列边缘调宽度</div>`;
}
// --- CONFIG LIST ---
function renderConfigList() {
const sorted = [...fields].sort((a,b) => a.order - b.order);
const el = document.getElementById('configList');
el.innerHTML = sorted.map(f => {
const t = TYPES.find(t => t.v === f.type) || {};
const a = f.id === activeId;
return `<div class="cfg-row px-3 ${a?'active':''}" data-fid="${f.id}"
draggable="true"
ondragstart="cfgDragStart(event,${f.id})"
ondragover="cfgDragOver(event)"
ondrop="cfgDrop(event,${f.id})"
ondragend="cfgDragEnd(event)">
<div class="flex items-center gap-2 py-2.5 pointer-events-none">
<!-- Drag handle -->
<svg class="drag-handle w-3.5 h-3.5 text-slate-300 shrink-0 pointer-events-auto" style="opacity:${a?1:''}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 8h16M4 16h16"/>
</svg>
<span class="w-5 h-5 rounded flex items-center justify-center text-[9px] font-bold text-white shrink-0" style="background:${t.c||'#94a3b8'}">${t.icon||'?'}</span>
<span class="text-xs font-medium text-slate-700 flex-1 min-w-0">
<span class="truncate">${f.label}</span>
<code class="text-[10px] text-slate-400 ml-1.5">${f.code}</code>
</span>
<span class="text-[10px] text-slate-400 shrink-0">${(groups.find(g=>g.id===f.groupId)||{}).name||'—'}</span>
<!-- Toggles (delegated, no stopPropagation needed) -->
<div class="flex items-center gap-1.5 shrink-0 pointer-events-auto">
<div class="flex items-center gap-0.5 cursor-pointer" data-toggle="listOn" data-fid="${f.id}" title="列表中显示 / 隐藏">
<span class="text-[10px] ${f.listOn?'text-green-600 font-medium':'text-slate-300'} select-none">L</span>
<div class="toggle ${f.listOn?'on':''}" style="width:26px;height:14px;pointer-events:none;"></div>
</div>
<div class="flex items-center gap-0.5 cursor-pointer" data-toggle="editOn" data-fid="${f.id}" title="编辑中显示 / 隐藏">
<span class="text-[10px] ${f.editOn?'text-blue-600 font-medium':'text-slate-300'} select-none">E</span>
<div class="toggle ${f.editOn?'on':''}" style="width:26px;height:14px;pointer-events:none;"></div>
</div>
<div class="flex items-center gap-0.5 cursor-pointer" data-toggle="queryOn" data-fid="${f.id}" title="启用 / 关闭查询">
<span class="text-[10px] ${f.queryOn?'text-amber-600 font-medium':'text-slate-300'} select-none">Q</span>
<div class="toggle ${f.queryOn?'on':''}" style="width:26px;height:14px;pointer-events:none;"></div>
</div>
</div>
</div>
<!-- Quick Edit -->
<div class="quick-edit pointer-events-auto">
<div class="grid grid-cols-2 gap-2 pb-3 border-t border-blue-100 pt-2">
<div><label class="text-[10px] text-slate-400 block mb-0.5">标签</label>
<input class="input-sm" value="${f.label|l}"
oninput="updateField(${f.id},'label',this.value)"></div>
<div><label class="text-[10px] text-slate-400 block mb-0.5">编码</label>
<input class="input-sm" value="${f.code|l}"
oninput="updateField(${f.id},'code',this.value)"></div>
<div><label class="text-[10px] text-slate-400 block mb-0.5">类型</label>
<select class="select-sm" onchange="onTypeChange(${f.id},this.value)">
${TYPES.map(td => `<option value="${td.v}" ${f.type===td.v?'selected':''}>${td.lbl}</option>`).join('')}
</select></div>
<div><label class="text-[10px] text-slate-400 block mb-0.5">默认值</label>
<input class="input-sm" value="${f.queryDef||''}" placeholder="无" oninput="updateField(${f.id},'queryDef',this.value)"></div>
<div><label class="text-[10px] text-slate-400 block mb-0.5">分组</label>
<select class="select-sm" onchange="updateField(${f.id},'groupId',this.value)">
<option value="">— 未分组 —</option>
${groups.map(g => `<option value="${g.id}" ${f.groupId===g.id?'selected':''}>${g.name}</option>`).join('')}
</select></div>
<div><label class="text-[10px] text-slate-400 block mb-0.5">列宽 ${f.listW}px</label>
<input type="range" min="40" max="400" value="${f.listW}" class="w-full accent-blue-500 h-1"
oninput="updateField(${f.id},'listW',parseInt(this.value))"></div>
<div class="col-span-2 flex items-center gap-3">
<label class="flex items-center gap-1.5 cursor-pointer text-[11px] text-slate-600">
<input type="checkbox" ${f.editReq?'checked':''} class="w-3 h-3 accent-blue-500"
onchange="toggleField(${f.id},'editReq')"> 必填
</label>
<label class="flex items-center gap-1.5 cursor-pointer text-[11px] text-slate-600">
<input type="checkbox" ${f.editRO?'checked':''} class="w-3 h-3 accent-blue-500"
onchange="toggleField(${f.id},'editRO')"> 只读
</label>
${f.queryOn ? `<select class="select-sm w-auto text-[11px]" onchange="updateField(${f.id},'queryOp',this.value)">
${['like','=','>=','<=','between','in'].map(op => `<option value="${op}" ${f.queryOp===op?'selected':''}>${op}</option>`).join('')}
</select>` : ''}
${f.type==='select' ? `<button class="btn btn-outline text-[10px] py-0.5 px-2" data-dsbtn data-fid="${f.id}">⚙ 数据源</button>` : ''}
<button class="text-slate-300 hover:text-red-400 text-[10px] ml-auto" data-delbtn data-fid="${f.id}">删除</button>
</div>
</div>
</div>
</div>`;
}).join('');
// Auto-scroll to active
if (activeId) {
setTimeout(() => {
const active = el.querySelector('.cfg-row.active');
if (active) active.scrollIntoView({ block:'nearest', behavior:'smooth' });
},50);
}
}
// ============================== DRAG & DROP ==============================
let dragId = null;
function cfgDragStart(e, id) {
dragId = id;
e.dataTransfer.effectAllowed = 'move';
e.currentTarget.style.opacity = '0.4';
}
function cfgDragOver(e) { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
function cfgDrop(e, targetId) {
e.preventDefault();
e.currentTarget.classList.remove('drag-over');
if (!dragId || dragId === targetId) return;
const s = fields.find(f => f.id === dragId), t = fields.find(f => f.id === targetId);
if (!s || !t) return;
[s.order, t.order] = [t.order, s.order];
renderAll();
}
function cfgDragEnd(e) {
e.currentTarget.style.opacity = '1';
document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
dragId = null;
}
// ============================== COLUMN RESIZE ==============================
let rs = null;
function startColResize(e, id) { e.preventDefault(); rs = { id, sx:e.clientX, sw:fields.find(f=>f.id===id)?.listW||100 }; addEventListener('mousemove', onColResize); addEventListener('mouseup', stopColResize); }
function onColResize(e) { if(!rs) return; const w=Math.max(40,Math.min(400,Math.round(rs.sw+e.clientX-rs.sx))); const f=fields.find(f=>f.id===rs.id); if(f){f.listW=w;renderAll()} }
function stopColResize() { rs=null; removeEventListener('mousemove',onColResize); removeEventListener('mouseup',stopColResize); }
// ============================== TOGGLES ==============================
function toggleField(id, key) { const f=fields.find(f=>f.id===id); if(f){f[key]=!f[key]; renderAll()} }
function updateField(id, key, val) { const f=fields.find(f=>f.id===id); if(f){f[key]=val; renderAll()} }
function onTypeChange(id, nt) { const f=fields.find(f=>f.id===id); if(!f) return; f.type=nt; if(nt==='select'&&!f.ds) f.ds={type:'view',view:'',vf:'b_id',lf:'b_name'}; if(nt!=='select') f.ds=null; renderAll() }
function addField() {
const f={ id:nextId++, code:'fld_'+(nextId-1), label:'新字段', type:'input', groupId:groups[0]?.id||null, order:fields.length*10+10, listOn:true, listW:120, listAlign:'left', editOn:true, editReq:false, editRO:false, queryOn:false, queryOp:'like', queryDef:'', ds:null };
fields.push(f); activeId=f.id; renderAll(); showToast('已添加新字段,右侧展开编辑');
}
function deleteField(id) {
const f=fields.find(f=>f.id===id); if(!f) return;
if(!confirm(`删除「${f.label}」(${f.code})?`)) return;
fields=fields.filter(f=>f.id!==id); if(activeId===id) activeId=null; renderAll(); showToast(`已删除「${f.label}」`);
}
// ============================== DATA SOURCE ==============================
function openDsDrawer(id) {
dsDrawerId=id; const f=fields.find(f=>f.id===id); if(!f) return;
const ds=f.ds;
document.getElementById('dsDrawerContent').innerHTML=`
<div class="space-y-4">
<div class="flex bg-slate-100 rounded-lg p-1">
<button class="flex-1 text-[11px] py-1.5 rounded-md transition-all ${ds?.type==='view'?'bg-white shadow-sm text-blue-600 font-medium':''}" onclick="switchDsType('view')">视图数据</button>
<button class="flex-1 text-[11px] py-1.5 rounded-md transition-all ${ds?.type==='static'?'bg-white shadow-sm text-blue-600 font-medium':''}" onclick="switchDsType('static')">静态选项</button>
<button class="flex-1 text-[11px] py-1.5 rounded-md transition-all ${!ds?'bg-white shadow-sm text-blue-600 font-medium':''}" onclick="switchDsType('none')">无</button>
</div>
${!ds?'<p class="text-xs text-slate-300 text-center mt-8">无数据源</p>':''}
${ds?.type==='view'?renderDsView(f):''}
${ds?.type==='static'?renderDsStatic(f):''}
</div>`;
document.getElementById('dsDrawer').classList.add('open');
document.getElementById('dsOverlay').classList.add('open');
}
function closeDsDrawer() { document.getElementById('dsDrawer').classList.remove('open'); document.getElementById('dsOverlay').classList.remove('open'); dsDrawerId=null; renderAll(); }
function switchDsType(t) { const f=fields.find(f=>f.id===dsDrawerId); if(!f) return; if(t==='none') f.ds=null; else if(t==='view') f.ds={type:'view',view:'',vf:'b_id',lf:'b_name',ob:'b_xh ASC'}; else f.ds={type:'static',items:[{v:'',l:''}]}; openDsDrawer(dsDrawerId); }
function renderDsView(f) { const ds=f.ds; return `<div class="bg-blue-50 border border-blue-100 rounded-lg p-3 space-y-3">
<div><label class="text-[11px] text-slate-500 block mb-1">视图</label><select class="select-sm bg-white"><option selected>${ds.view||'v_carrier'}</option><option>v_port</option><option>v_partner</option><option>v_container</option></select></div>
<div class="grid grid-cols-2 gap-2"><div><label class="text-[11px] text-slate-500 block mb-1">值</label><select class="select-sm bg-white"><option selected>${ds.vf||'b_id'}</option><option>b_code</option><option>b_name</option></select></div><div><label class="text-[11px] text-slate-500 block mb-1">标签</label><select class="select-sm bg-white"><option selected>${ds.lf||'b_name'}</option><option>b_code</option><option>b_id</option></select></div></div>
<div><label class="text-[11px] text-slate-500 block mb-1">排序</label><input class="input-sm bg-white" value="${ds.ob||''}" placeholder="b_xh ASC"></div>
<div class="bg-white rounded-lg border border-slate-100 overflow-hidden"><div class="text-[11px] text-slate-400 px-2.5 py-1.5 bg-slate-50">预览</div><div class="text-[11px] text-slate-500 px-2.5 py-1 border-b border-slate-50">1 → MAERSK</div><div class="text-[11px] text-slate-500 px-2.5 py-1 border-b border-slate-50">2 → MSC</div><div class="text-[11px] text-slate-500 px-2.5 py-1">3 → CMA CGM</div></div>
</div>`; }
function renderDsStatic(f) { const ds=f.ds; return `<div class="bg-purple-50 border border-purple-100 rounded-lg p-3 space-y-2">
${(ds.items||[]).map(it => `<div class="flex items-center gap-1.5"><input class="input-sm flex-1 bg-white" value="${it.v}" placeholder="值"><span class="text-slate-300 text-[10px]">→</span><input class="input-sm flex-1 bg-white" value="${it.l}" placeholder="标签"><button class="text-slate-300 hover:text-red-400 shrink-0 p-0.5">✕</button></div>`).join('')}
<button class="text-[11px] text-blue-500 hover:text-blue-600 flex items-center gap-1 mt-1">+ 添加选项</button></div>`; }
// ============================== PREVIEW / EDIT MODE ==============================
function switchPreview(mode) { previewMode=mode; document.querySelectorAll('.preview-btn').forEach(b=>b.className=`preview-btn px-3 py-1 text-[11px] rounded transition-all ${b.dataset.pv===mode?'bg-white shadow-sm text-blue-600 font-medium':'text-slate-500'}`); renderAll(); }
function switchEditMode(mode) { editMode=mode; document.querySelectorAll('.editmode-btn').forEach(b=>b.className=`editmode-btn flex-1 text-[11px] py-1.5 rounded font-medium transition-all ${b.dataset.mode===mode?'bg-white shadow-sm text-blue-600':'text-slate-500'}`); renderAll(); }
function highlightGroup(gid) {
const g = groups.find(g=>g.id===gid); if(!g) return;
const first = fields.filter(f=>f.groupId===gid).sort((a,b)=>a.order-b.order)[0];
activeId = first ? first.id : null;
renderAll();
}
// ============================== GROUP ==============================
function addGroup() { const n=prompt('分组名称:'); if(!n) return; groups.push({id:'g'+Date.now(),name:n}); renderAll(); showToast(`已添加分组「${n}」`); }
function renameGroup(id) { const g=groups.find(g=>g.id===id); if(!g) return; const n=prompt('新名称:',g.name); if(n){g.name=n;renderAll()} }
// ============================== CLICK DELEGATION FOR MODE BUTTONS ==============================
document.getElementById('editModeSwitcher').addEventListener('click', e => {
const b = e.target.closest('.editmode-btn'); if(!b) return;
switchEditMode(b.dataset.mode);
});
document.getElementById('previewSwitcher').addEventListener('click', e => {
const b = e.target.closest('.preview-btn'); if(!b) return;
switchPreview(b.dataset.pv);
});
// ============================== TOAST ==============================
function showToast(m) { const e=document.getElementById('toast'); e.textContent=m; e.classList.add('show'); clearTimeout(e._t); e._t=setTimeout(()=>e.classList.remove('show'),2000); }
// ============================== FILTERS ==============================
// Safe filter for template literals with potential null/undefined
function l(v) { return v == null ? '' : v; }
// Init
renderAll();
</script>
</body>
</html>