506 lines
34 KiB
HTML
506 lines
34 KiB
HTML
<!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>
|