You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

657 lines
40 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>数据字典 E2E Demo(dict-alignment)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 14px/1.6 "Microsoft YaHei", system-ui, sans-serif; color: #333; background: #f5f6fa; }
header { display: flex; align-items: center; gap: 14px; padding: 10px 20px; background: #1f2d3d; color: #fff; flex-wrap: wrap; }
header h1 { font-size: 17px; font-weight: 600; }
header select, header button, header input { padding: 4px 10px; border-radius: 4px; border: 1px solid #45566b; background: #2c3e50; color: #fff; }
header button.primary { background: #409eff; border-color: #409eff; cursor: pointer; }
header .who { color: #9fb3c8; }
nav.tabs { display: flex; gap: 4px; padding: 0 20px; background: #fff; border-bottom: 1px solid #e4e7ed; }
nav.tabs button { padding: 10px 22px; border: none; background: none; font-size: 14px; cursor: pointer; color: #666; border-bottom: 2px solid transparent; }
nav.tabs button.on { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
main { padding: 16px 20px; }
.panel { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 14px; margin-bottom: 14px; }
.viewbar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
.viewbar .sp { flex: 1; }
input, select, textarea { padding: 5px 8px; border: 1px solid #dcdfe6; border-radius: 4px; font: inherit; }
button.act { padding: 5px 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
button.act.primary { background: #409eff; color: #fff; border-color: #409eff; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 8px 10px; border-bottom: 1px solid #ebeef5; white-space: nowrap; }
th { color: #909399; font-weight: 500; font-size: 13px; background: #fafafa; }
td.ops button { padding: 2px 8px; margin-right: 4px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; }
td.ops button.warn { color: #d84a4a; border-color: #f3c1c1; }
td.ops button:disabled { color: #bbb; cursor: not-allowed; }
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; margin-right: 4px; }
.b1 { background: #ecf0f5; color: #606266; } .b2 { background: #e8f7e8; color: #2fa52f; }
.b3 { background: #fdf0e0; color: #d9820c; } .b4 { background: #fdeaea; color: #d84a4a; }
.b5 { background: #efe7fb; color: #8a5bd6; }
.empty { color: #999; text-align: center; padding: 24px 0; }
.pager { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding: 10px 0 0; }
.pager button { padding: 3px 10px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
#toast { position: fixed; top: 14px; right: 14px; z-index: 99; display: flex; flex-direction: column; gap: 8px; max-width: 520px; }
.toast-item { padding: 10px 14px; border-radius: 6px; color: #fff; font-size: 13px; word-break: break-all; }
.toast-ok { background: #52c41a; } .toast-err { background: #d84a4a; } .toast-info { background: #3b7dbd; }
dialog { border: 1px solid #dcdfe6; border-radius: 8px; padding: 0; min-width: 480px; max-width: 720px; }
dialog::backdrop { background: rgba(0,0,0,.35); }
dialog .dhead { padding: 12px 16px; background: #f5f7fa; border-bottom: 1px solid #e4e7ed; font-weight: 600; }
dialog .dbody { padding: 14px 16px; display: flex; flex-direction: column; gap: 10px; max-height: 70vh; overflow: auto; }
dialog .dbody label { display: flex; align-items: center; gap: 10px; }
dialog .dbody label b { width: 110px; color: #606266; font-weight: 400; text-align: right; flex-shrink: 0; }
dialog .dbody input, dialog .dbody select, dialog .dbody textarea { flex: 1; }
dialog .dfoot { padding: 10px 16px; text-align: right; border-top: 1px solid #e4e7ed; display: flex; gap: 8px; justify-content: flex-end; }
dialog .dfoot button { padding: 6px 18px; border-radius: 4px; border: 1px solid #dcdfe6; background: #fff; cursor: pointer; }
dialog .dfoot button.ok { background: #409eff; color: #fff; border-color: #409eff; }
.errbox { background: #fff4f4; border: 1px solid #f3c1c1; color: #b03030; padding: 8px 12px; border-radius: 4px; font-size: 12px; white-space: pre-wrap; }
.okbox { background: #f4fff4; border: 1px solid #bfe3bf; color: #2a7a2a; padding: 8px 12px; border-radius: 4px; font-size: 12px; white-space: pre-wrap; }
.hint { color: #999; font-size: 12px; }
.mark { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 8px; background: #ececec; color: #8a8a8a; font-size: 11px; cursor: help; }
/* 两级树 */
.tree { line-height: 2; }
.tree .lv1 { font-weight: 600; }
.tree .lv1 > .tnode { padding: 2px 6px; border-radius: 4px; }
.tree .lv2 { margin-left: 28px; }
.tree .lv2 .tnode { color: #556; }
/* 引用联测 */
.refbox .step { display: flex; gap: 10px; align-items: flex-start; padding: 10px 0; border-bottom: 1px dashed #ebeef5; }
.refbox .step b { flex-shrink: 0; width: 46px; color: #909399; }
.reflog { font-family: Consolas, monospace; font-size: 12px; background: #1f2d3d; color: #cde; padding: 10px 14px; border-radius: 6px; max-height: 320px; overflow: auto; white-space: pre-wrap; }
.kv { display: flex; gap: 8px; } .kv b { color: #909399; font-weight: 400; min-width: 96px; }
</style>
</head>
<body>
<header>
<h1>数据字典 E2E Demo</h1>
<span class="hint" id="apiBox"></span>
<span>当前用户:</span>
<select id="userSel">
<option value="739564171091247104">罗伟健(admin)</option>
<option value="744842318024015872">曾偲青(BUDDY)</option>
</select>
<input id="uidInput" placeholder="测试环境 userId 直登" style="width:230px">
<button class="primary" onclick="loginByUid()">uid 登录</button>
<span class="who" id="whoBox">未登录</span>
</header>
<nav class="tabs">
<button data-tab="group" class="on" onclick="switchTab('group')">分组管理</button>
<button data-tab="item" onclick="switchTab('item')">字典项管理</button>
<button data-tab="tree" onclick="switchTab('tree')">两级树</button>
<button data-tab="ref" onclick="switchTab('ref')">引用计数联测</button>
</nav>
<main>
<!-- ========== Tab 1 分组管理 ========== -->
<section id="tab-group">
<div class="panel">
<div class="viewbar">
<input id="gKeyword" placeholder="名称/编码" style="width:170px">
<select id="gStatus"><option value="">全部状态</option><option value="1">启用</option><option value="0">停用</option></select>
<button class="act" onclick="S_G.page=1;loadGroups()">查询</button>
<span class="sp"></span>
<button class="act primary" onclick="dlgGroup()">+ 新建分组</button>
</div>
<table><thead><tr>
<th>分组名称</th><th>分组编码</th><th>排序</th><th>状态</th><th>字典项数</th><th>描述</th><th>操作</th>
</tr></thead><tbody id="gBody"></tbody></table>
<div class="pager">
<button onclick="loadGroups(-1)">上一页</button><span id="gPageInfo">-</span><button onclick="loadGroups(1)">下一页</button>
</div>
</div>
<div class="panel hint">
拍板口径:新建分组编码必填且全局唯一(63002);编辑弹窗编码只读、<b>状态可直接改保存即生效</b>(拍板 5);
删除要求组内无字典项且非内置(63004 / 63003)。
</div>
</section>
<!-- ========== Tab 2 字典项管理 ========== -->
<section id="tab-item" style="display:none">
<div class="panel">
<div class="viewbar">
<select id="iGroup" onchange="S_I.groupId=this.value;S_I.page=1;loadItems()"><option value="">全部分组</option></select>
<input id="iKeyword" placeholder="名称/编码/键值" style="width:170px">
<select id="iStatus"><option value="">全部状态</option><option value="1">启用</option><option value="0">停用</option></select>
<button class="act" onclick="S_I.page=1;loadItems()">查询</button>
<span class="sp"></span>
<button class="act primary" onclick="dlgItem()">+ 新建字典项</button>
</div>
<table><thead><tr>
<th>分组</th><th>名称</th><th>编码</th><th>键值</th><th>排序</th><th>状态</th><th>默认</th><th>标记</th><th>操作</th>
</tr></thead><tbody id="iBody"></tbody></table>
<div class="pager">
<button onclick="loadItems(-1)">上一页</button><span id="iPageInfo">-</span><button onclick="loadItems(1)">下一页</button>
</div>
</div>
<div class="panel hint">
拍板口径:键值必填(空白 63001);编码空自动生成 <code>dict_</code>+UUID、创建后只读(63008);
<b>分组默认项禁止停用</b>(/status 与 saveOrUpdate 两路同拦 63013,拍板 2);
<b>键值被引用后仍可改</b>(拍板 4);<b>未被引用且无子项可换组</b>(目标组启用 + 编码/键值双重唯一 + 计数行随迁,拍板 6);
删除被引用阻断(63009),force-delete 为计数飘高兜底;设默认同事务自动取消同组原默认。
</div>
</section>
<!-- ========== Tab 3 两级树 ========== -->
<section id="tab-tree" style="display:none">
<div class="panel">
<div class="viewbar">
<select id="tGroup" onchange="loadTree()"></select>
<button class="act" onclick="loadTree()">刷新</button>
<span class="hint">GET /api/dict/item/tree?groupCode=(票 04 增项,拍板 10;走 10s 本地缓存)</span>
</div>
<div id="treeBox" class="tree"><div class="empty">选择分组后加载</div></div>
</div>
</section>
<!-- ========== Tab 4 引用计数联测 ========== -->
<section id="tab-ref" style="display:none">
<div class="panel refbox">
<b>消费方引用计数联测(票 07:ref_count 全量接线,customer 域 customer_type 分组)</b>
<div class="hint" style="margin:6px 0 10px">
当前联测字典项:<span id="refInfo">未选择(去「字典项管理」点行内「联测」,customer_type 分组项才可联测)</span>
</div>
<div class="step"><b>步骤0</b>
<span>
<button class="act" onclick="refPrepare()">准备演示字典项(幂等建 e2d_type_a / e2d_type_b)</button>
<span class="hint">走 POST /api/dict/item/saveOrUpdate:键值必填、编码组内唯一(63006/63007 幂等可重跑)</span>
</span>
</div>
<div class="step"><b>步骤1</b>
<span>
<button class="act" onclick="refCreateCustomer()">新建客户引用此字典</button>
<span class="hint">POST /api/customer/create(confirmSimilar=true)→ 字典项 referenced=true(ref_count +1,同事务入账)</span>
</span>
</div>
<div class="step"><b>步骤2</b>
<span>
<button class="act" onclick="refSwapCustomer()">编辑客户换引用到 e2d_type_b</button>
<span class="hint">POST /api/customer/edit?id=(CAS version)→ 旧项 decrement、新项 increment(applyFieldChange 随迁)</span>
</span>
</div>
<div class="step"><b>步骤3</b>
<span>
<button class="act" onclick="refDeleteBlocked()">尝试删除当前被引用项</button>
<span class="hint">POST /api/dict/item/delete → 期望 63009「该字典已被引用,无法删除」(票 04 后引用阻断真实生效)</span>
</span>
</div>
<div class="step"><b>步骤4</b>
<span>
<button class="act" onclick="refArchive()">归档演示客户(清理)</button>
<span class="hint">POST /api/customer/archive?id=;归档保留字典编码引用计数(数据一致性优先)</span>
</span>
</div>
<div style="margin-top:10px" id="refOut"></div>
</div>
<div class="panel"><b>执行日志</b><div id="refLog" class="reflog">(等待操作)</div></div>
</section>
</main>
<div id="toast"></div>
<dialog id="dlg">
<div class="dhead" id="dlgTitle"></div>
<div class="dbody" id="dlgBody"></div>
<div class="dfoot" id="dlgFoot"></div>
</dialog>
<script>
/* ================= 常量与工具 ================= */
const API_BASE = (() => {
const q = new URLSearchParams(location.search).get('api');
if (q !== null) {
if (q && q !== 'reset') localStorage.setItem('e2d_api', q);
else localStorage.removeItem('e2d_api');
}
const saved = localStorage.getItem('e2d_api');
if (saved) return saved;
return location.protocol.startsWith('http') ? '' : 'http://localhost:8080';
})();
const STATUS_TXT = { 1: '启用', 0: '停用' };
const $ = id => document.getElementById(id);
const esc = s => s == null ? '' : String(s).replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const TOKEN = () => localStorage.getItem('e2d_token');
const ME = () => { try { return JSON.parse(localStorage.getItem('e2d_me') || 'null'); } catch (e) { return null; } };
const codeSuffix = e => e && e.code ? ' (code=' + e.code + ')' : '';
function toast(msg, type) {
const d = document.createElement('div');
d.className = 'toast-item toast-' + (type === 'ok' ? 'ok' : type === 'err' ? 'err' : 'info');
d.textContent = msg;
$('toast').appendChild(d);
setTimeout(() => d.remove(), 6000);
}
function qs(o) {
return Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '')
.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
}
function errHtml(e) { return '<div class="errbox">失败: ' + esc(e.message) + codeSuffix(e) + '</div>'; }
async function api(path, opt = {}) {
const headers = opt.headers || (opt.headers = {});
if (!(opt.body instanceof FormData)) headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded;charset=UTF-8';
if (TOKEN()) headers['Authorization'] = 'Bearer ' + TOKEN();
const r = await fetch(API_BASE + path, opt);
let j = null;
try { j = await r.json(); } catch (e) {}
if (r.status === 401) { toast('未登录或 token 失效,请先在页头登录', 'err'); throw new Error('401 未授权'); }
if (j && j.code !== 0 && j.code !== undefined) {
if (j.code === 40401 || r.status === 404) toast('接口不存在(404):' + path, 'err');
const e = new Error(j.message || ('HTTP ' + r.status)); e.code = j.code; e.data = j.data; throw e;
}
if (!r.ok) throw new Error('HTTP ' + r.status);
return j ? j.data : null;
}
/* ================= 登录 ================= */
async function loginByUid() {
const uid = ($('uidInput').value || '').trim() || $('userSel').value;
if (!uid) return toast('请输入 userId', 'err');
try {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + encodeURIComponent(uid));
const j = await r.json();
const d = j && j.data;
const tok = typeof d === 'string' ? d : (d && d.token);
if (!tok) return toast('debug/token 失败: ' + ((j && j.message) || r.status), 'err');
localStorage.setItem('e2d_token', tok);
let me = null;
try {
const r2 = await fetch(API_BASE + '/api/auth/me', { headers: { Authorization: 'Bearer ' + tok } });
const j2 = await r2.json();
if (j2 && j2.code === 0) me = j2.data;
} catch (e) {}
if (!me) me = { userId: uid };
localStorage.setItem('e2d_me', JSON.stringify(me));
paintWho();
toast('登录成功: ' + (me.userName || me.nickName || uid), 'ok');
S_G.page = 1; loadGroups(); loadGroupOptions();
} catch (e) { toast('登录异常: ' + e.message, 'err'); }
}
function paintWho() {
const me = ME();
$('whoBox').textContent = me ? '已登录:' + (me.userName || me.nickName || '') + ' (' + (me.userId || '') + ')' : '未登录';
}
/* ================= Tab 切换 ================= */
function switchTab(t) {
document.querySelectorAll('nav.tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === t));
document.querySelectorAll('main > section').forEach(s => s.style.display = s.id === 'tab-' + t ? '' : 'none');
if (t === 'item') loadGroupOptions(); // 分组可能刚在分组页新建/启停,进字典项页刷新下拉
if (t === 'tree' && !$('tGroup').options.length) loadGroupOptions().then(loadTree);
}
/* ================= 弹窗 ================= */
function dlgOpen(title, bodyHtml, foots) {
$('dlgTitle').textContent = title;
$('dlgBody').innerHTML = bodyHtml;
$('dlgFoot').innerHTML = foots.map((f, i) => '<button class="' + (f.ok ? 'ok' : '') + '" data-i="' + i + '">' + esc(f.text) + '</button>').join('');
$('dlgFoot').querySelectorAll('button').forEach(btn => {
btn.onclick = () => { const f = foots[+btn.dataset.i]; if (f.fn) f.fn(); else dlgClose(); };
});
$('dlg').showModal();
}
function dlgClose() { $('dlg').close(); }
function formVal(id) { return ($(id) && $(id).value || '').trim(); }
/* ================= Tab1 分组管理 ================= */
const S_G = { page: 1, size: 10, total: 0 };
async function loadGroups(dir) {
try {
if (dir) S_G.page = Math.min(Math.max(1, S_G.page + dir), Math.max(1, Math.ceil(S_G.total / S_G.size)));
const d = await api('/api/dict/group/page', { method: 'POST', body: qs({
current: S_G.page, size: S_G.size, keyword: formVal('gKeyword'), status: formVal('gStatus') }) });
S_G.rows = d.content || []; S_G.total = d.total;
$('gPageInfo').textContent = '第 ' + d.current + ' / ' + Math.max(1, d.pages) + ' 页,共 ' + d.total + ' 条';
$('gBody').innerHTML = S_G.rows.length ? S_G.rows.map(g => '<tr>' +
'<td>' + esc(g.name) + (g.builtin ? ' <span class="mark" title="内置分组不可删">内置</span>' : '') + '</td>' +
'<td>' + esc(g.code) + '</td><td>' + esc(g.sortNo) + '</td>' +
'<td>' + (g.status === 1 ? '<span class="badge b2">启用</span>' : '<span class="badge b4">停用</span>') + '</td>' +
'<td>' + esc(g.itemCount) + '</td><td style="white-space:normal">' + esc(g.description) + '</td>' +
'<td class="ops">' +
'<button onclick="dlgGroup(\'' + g.id + '\')">编辑</button>' +
'<button onclick="groupStatus(\'' + g.id + '\',\'' + (g.status === 1 ? 0 : 1) + '\')">' + (g.status === 1 ? '停用' : '启用') + '</button>' +
'<button class="warn" onclick="groupDelete(\'' + g.id + '\',\'' + esc(g.code) + '\')">删除</button>' +
'<button onclick="gotoItems(\'' + g.id + '\')">查看字典项</button>' +
'</td></tr>').join('') : '<tr><td colspan="7" class="empty">暂无分组</td></tr>';
} catch (e) { $('gBody').innerHTML = '<tr><td colspan="7" class="errbox">' + esc(e.message) + codeSuffix(e) + '</td></tr>'; }
}
async function dlgGroup(id) {
let g = {};
if (id) g = await api('/api/dict/group/page', { method: 'POST', body: qs({ current: 1, size: 500, keyword: '' }) })
.then(d => (d.content || []).find(x => String(x.id) === String(id))) || {};
dlgOpen(id ? '编辑分组(编码只读;状态可直接改,保存即生效——拍板5)' : '新建分组(编码必填、全局唯一)', `
<label><b>分组名称*</b><input id="fgName" value="${esc(g.name)}"></label>
<label><b>分组编码</b><input id="fgCode" value="${esc(g.code)}" ${id ? 'readonly style="background:#f5f7fa"' : ''} placeholder="字母/数字/下划线${id ? ',创建后只读' : ''}"></label>
<label><b>排序号</b><input id="fgSort" value="${esc(g.sortNo == null ? '' : g.sortNo)}" placeholder="非负整数"></label>
<label><b>状态</b><select id="fgStatus"><option value="1" ${g.status === 1 ? 'selected' : ''}>启用</option><option value="0" ${g.status === 0 ? 'selected' : ''}>停用</option></select></label>
<label><b>描述</b><textarea id="fgDesc" rows="2">${esc(g.description)}</textarea></label>
<div id="fgOut"></div>`,
[{ text: '取消', fn: dlgClose }, { text: '保存', ok: true, fn: () => saveGroup(id) }]);
}
async function saveGroup(id) {
try {
const body = { name: formVal('fgName'), code: formVal('fgCode'), sortNo: formVal('fgSort'),
status: formVal('fgStatus'), description: formVal('fgDesc') };
if (id) { await api('/api/dict/group/saveOrUpdate?id=' + id, { method: 'POST', body: qs(body) }); }
else { await api('/api/dict/group/saveOrUpdate', { method: 'POST', body: qs(body) }); }
toast('分组保存成功', 'ok'); dlgClose(); loadGroups(); loadGroupOptions();
} catch (e) { $('fgOut').innerHTML = errHtml(e); }
}
async function groupStatus(id, status) {
try { await api('/api/dict/group/status?id=' + id + '&status=' + status, { method: 'POST' });
toast('分组状态已更新', 'ok'); loadGroups(); loadGroupOptions();
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
async function groupDelete(id, code) {
if (!confirm('确认删除分组 ' + code + '?(组内有字典项将阻断 63004)')) return;
try { await api('/api/dict/group/delete?id=' + id, { method: 'POST' });
toast('分组已删除', 'ok'); loadGroups(); loadGroupOptions();
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
function gotoItems(groupId) {
switchTab('item'); S_I.groupId = String(groupId); S_I.page = 1; $('iGroup').value = S_I.groupId; loadItems();
}
/* ================= Tab2 字典项管理 ================= */
const S_I = { page: 1, size: 10, total: 0, groupId: '', rows: [] };
const REF_DEMO_GROUP = 'customer_type'; // 客户域联测分组(票 07 接线)
async function loadGroupOptions() {
try {
const list = await api('/api/dict/group/enabled-list');
const opt = list.map(g => '<option value="' + esc(g.id) + '">' + esc(g.name) + '(' + esc(g.code) + ')</option>').join('');
const iOpts = '<option value="">全部分组</option>' + opt;
$('iGroup').innerHTML = iOpts; if (S_I.groupId) $('iGroup').value = S_I.groupId;
// 树端点按 groupCode 查询(票04),下拉 value 必须传 code 而非 id
$('tGroup').innerHTML = list.map(g => '<option value="' + esc(g.code) + '">' + esc(g.name) + '(' + esc(g.code) + ')</option>').join('');
} catch (e) { toast('分组下拉加载失败: ' + e.message + codeSuffix(e), 'err'); }
}
async function loadItems(dir) {
try {
if (dir) S_I.page = Math.min(Math.max(1, S_I.page + dir), Math.max(1, Math.ceil(S_I.total / S_I.size)));
const d = await api('/api/dict/item/page', { method: 'POST', body: qs({
current: S_I.page, size: S_I.size, groupId: S_I.groupId, keyword: formVal('iKeyword'), status: formVal('iStatus') }) });
S_I.rows = d.content || []; S_I.total = d.total;
$('iPageInfo').textContent = '第 ' + d.current + ' / ' + Math.max(1, d.pages) + ' 页,共 ' + d.total + ' 条';
$('iBody').innerHTML = S_I.rows.length ? S_I.rows.map(i => '<tr>' +
'<td>' + esc(i.groupName) + '</td>' +
'<td>' + esc(i.name) + (i.parentId ? ' <span class="mark" title="二级字典项">二级</span>' : '') + '</td>' +
'<td>' + esc(i.code) + '</td><td>' + esc(i.value) + '</td><td>' + esc(i.sortNo) + '</td>' +
'<td>' + (i.status === 1 ? '<span class="badge b2">启用</span>' : '<span class="badge b4">停用</span>') + '</td>' +
'<td>' + (i.isDefault ? '<span class="badge b5">默认</span>' : '<span class="mark">否</span>') + '</td>' +
'<td>' + (i.referenced ? '<span class="badge b3">被引用</span>' : '') +
(i.status === 1 && !i.effectiveSelectable ? '<span class="badge b1" title="自身启用但所属分组停用">失活</span>' : '') + '</td>' +
'<td class="ops">' +
'<button onclick="dlgItem(\'' + i.id + '\')">编辑</button>' +
'<button onclick="itemStatus(\'' + i.id + '\',\'' + (i.status === 1 ? 0 : 1) + '\')">' + (i.status === 1 ? '停用' : '启用') + '</button>' +
'<button onclick="itemSetDefault(\'' + i.groupId + '\',\'' + i.id + '\')">设默认</button>' +
'<button class="warn" onclick="itemDelete(\'' + i.id + '\',\'' + esc(i.code) + '\')">删除</button>' +
'<button onclick="refPick(\'' + i.id + '\')">联测</button>' +
'</td></tr>').join('') : '<tr><td colspan="9" class="empty">暂无字典项</td></tr>';
} catch (e) { $('iBody').innerHTML = '<tr><td colspan="9" class="errbox">' + esc(e.message) + codeSuffix(e) + '</td></tr>'; }
}
// 父项(二级)下拉 = 所属分组下的一级项(parentId 为空,排除自身);后端校验:同组、父项须为一级、不得自引用
async function refreshParentOptions(groupId, selectedParentId, selfId) {
const sel = $('fiParent');
if (!sel) return;
let opts = '<option value="">(无,作为一级项)</option>';
let found = false;
if (groupId) {
try {
const d = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 500, groupId: groupId }) });
const tops = (d.content || []).filter(x => x.parentId == null && (!selfId || String(x.id) !== String(selfId)));
opts += tops.map(x => '<option value="' + esc(x.id) + '" ' +
(selectedParentId && String(x.id) === String(selectedParentId) ? 'selected' : '') + '>' +
esc(x.name) + '(' + esc(x.code) + (x.status === 0 ? '·停用' : '') + ')</option>').join('');
found = tops.some(x => String(x.id) === String(selectedParentId));
} catch (e) { /* 目标组不可查时保持仅「无」;保存走后端校验兜底 */ }
}
if (selectedParentId && !found) opts += '<option value="' + esc(selectedParentId) + '" selected>当前父项 #' + esc(selectedParentId) + '</option>';
sel.innerHTML = opts;
}
async function dlgItem(id) {
const groups = await api('/api/dict/group/enabled-list').catch(() => []);
let it = {};
if (id) {
it = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 500 }) })
.then(d => (d.content || []).find(x => String(x.id) === String(id))) || {};
}
// 编辑态下拉 = 启用分组 ∪ 当前所属分组(换组仅目标启用组可选;拍板6)
const merged = groups.slice();
if (it.groupId && !merged.some(g => String(g.id) === String(it.groupId))) merged.unshift({ id: it.groupId, name: it.groupName + '(当前·已停用)', code: '' });
// 默认所属分组:编辑=当前组;新建=列表过滤中的分组(在分组视图下新建即建到该组),否则首个启用组兜底
const curGroup = it.groupId || (S_I && S_I.groupId) || (merged[0] && merged[0].id) || '';
const gOpts = merged.map(g => '<option value="' + esc(g.id) + '" ' + (String(g.id) === String(curGroup) ? 'selected' : '') + '>' +
esc(g.name) + (g.code ? '(' + esc(g.code) + ')' : '') + '</option>').join('');
dlgOpen(id ? '编辑字典项(键值必填;编码只读;未被引用且无子项可换组——拍板6)' : '新建字典项(键值必填;编码留空自动生成 dict_+UUID)', `
<label><b>所属分组*</b><select id="fiGroup">${gOpts}</select></label>
<label><b>父项(二级)</b><select id="fiParent"></select><span class="hint">同组一级项,留空=一级项</span></label>
<label><b>字典名称*</b><input id="fiName" value="${esc(it.name)}"></label>
<label><b>字典键值*</b><input id="fiValue" value="${esc(it.value)}" placeholder="必填,空白报 63001(拍板3)"></label>
<label><b>字典编码</b><input id="fiCode" value="${esc(it.code)}" ${id ? 'readonly style="background:#f5f7fa"' : ''} placeholder="留空自动生成;创建后只读(63008)"></label>
<label><b>排序号</b><input id="fiSort" value="${esc(it.sortNo == null ? '' : it.sortNo)}"></label>
<label><b>状态</b><select id="fiStatus"><option value="1" ${it.status !== 0 ? 'selected' : ''}>启用</option><option value="0" ${it.status === 0 ? 'selected' : ''}>停用</option></select>
<span class="hint">改为停用若撞分组默认项 → 63013(拍板2)</span></label>
<label><b>设为分组默认项</b><input type="checkbox" id="fiDefault" ${it.isDefault ? 'checked disabled' : ''}><span class="hint">${it.isDefault ? '已是默认项;取消默认=将同组其他项设为默认' : '保存后联动 set-default,同组原默认同事务自动取消'}</span></label>
<label><b>描述</b><textarea id="fiDesc" rows="2">${esc(it.description)}</textarea></label>
<div id="fiOut"></div>`,
[{ text: '取消', fn: dlgClose }, { text: '保存', ok: true, fn: () => saveItem(id) }]);
window._itemOrigGroup = it.groupId || '';
// 父项下拉随所属分组联动(父项必须同组);编辑态选回原组不重置,避免误清原父项
$('fiGroup').onchange = () => {
if ($('fiGroup').value !== String(window._itemOrigGroup || '')) refreshParentOptions($('fiGroup').value, null, id);
};
await refreshParentOptions(curGroup, it.parentId, id);
}
async function saveItem(id) {
try {
const body = { groupId: formVal('fiGroup'), parentId: formVal('fiParent'), name: formVal('fiName'),
value: formVal('fiValue'), code: formVal('fiCode'), sortNo: formVal('fiSort'),
status: formVal('fiStatus'), description: formVal('fiDesc') };
if (window._itemOrigGroup && body.groupId !== String(window._itemOrigGroup)) {
if (!confirm('检测到换组:未被引用且无存活子项才放行,目标组内编码/键值双重唯一,继续?')) return;
}
if (id) await api('/api/dict/item/saveOrUpdate?id=' + id, { method: 'POST', body: qs(body) });
else await api('/api/dict/item/saveOrUpdate', { method: 'POST', body: qs(body) });
await linkDefaultAfterSave(id, body);
toast('字典项保存成功', 'ok'); dlgClose(); loadItems();
} catch (e) { $('fiOut').innerHTML = errHtml(e); }
}
// 「设为分组默认项」勾选联动:保存成功后调 set-default(同事务自动取消同组原默认)。
// saveOrUpdate 返回 Void 拿不到新建 id → 用 groupId 过滤 + 键值(同组唯一)反查;
// 换组场景跳过联动(默认记录归属旧组,先落组再另行设默认),避免旧组默认记录悬空。
async function linkDefaultAfterSave(id, body) {
const cb = $('fiDefault');
if (!cb || !cb.checked || cb.disabled || !body.groupId) return;
if (window._itemOrigGroup && body.groupId !== String(window._itemOrigGroup)) return;
let itemId = id;
if (!itemId) {
const pg = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 500, groupId: body.groupId }) });
const hit = (pg.content || []).find(x => String(x.groupId) === String(body.groupId) && x.value === body.value);
if (!hit) return;
itemId = hit.id;
}
await api('/api/dict/item/set-default?groupId=' + body.groupId + '&itemId=' + itemId, { method: 'POST' });
}
async function itemStatus(id, status) {
try { await api('/api/dict/item/status?id=' + id + '&status=' + status, { method: 'POST' });
toast('字典项状态已更新', 'ok'); loadItems();
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
async function itemSetDefault(groupId, itemId) {
try { await api('/api/dict/item/set-default?groupId=' + groupId + '&itemId=' + itemId, { method: 'POST' });
toast('已设为分组默认项(同组原默认自动取消)', 'ok'); loadItems();
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
}
async function itemDelete(id, code) {
if (!confirm('确认删除字典项 ' + code + '?(被引用 63009 / 有子项 / 内置 63003 均阻断)')) return;
try { await api('/api/dict/item/delete?id=' + id, { method: 'POST' });
toast('字典项已删除', 'ok'); loadItems();
} catch (e) {
if (e.code === 63009 && confirm('被引用阻断(63009)。是否使用强制删除(force-delete,计数飘高兜底入口)?')) {
try { await api('/api/dict/item/force-delete?id=' + id, { method: 'POST' });
toast('已强制删除', 'ok'); loadItems(); return;
} catch (e2) { toast('强删失败: ' + e2.message + codeSuffix(e2), 'err'); return; }
}
toast('失败: ' + e.message + codeSuffix(e), 'err');
}
}
/* ================= Tab3 两级树 ================= */
async function loadTree() {
const gc = $('tGroup').value;
if (!gc) { $('treeBox').innerHTML = '<div class="empty">请选择分组</div>'; return; }
try {
const list = await api('/api/dict/item/tree?groupCode=' + encodeURIComponent(gc));
if (!list || !list.length) { $('treeBox').innerHTML = '<div class="empty">该分组暂无启用项</div>'; return; }
$('treeBox').innerHTML = list.map(n => {
const kids = (n.children || []).map(c =>
'<div class="lv2"><span class="tnode">├ ' + esc(c.name) + '(' + esc(c.code) + ')</span></div>').join('');
return '<div class="lv1"><span class="tnode">' + esc(n.name) + '(' + esc(n.code) + ')' +
(n.children && n.children.length ? ' <span class="mark">' + n.children.length + ' 子项</span>' : '') + '</span></div>' + kids;
}).join('');
} catch (e) { $('treeBox').innerHTML = '<div class="errbox">' + esc(e.message) + codeSuffix(e) + '</div>'; }
}
/* ================= Tab4 引用计数联测 ================= */
const REF = { item: null, customerId: null, customerName: null, version: null };
function refPick(itemId) {
const it = S_I.rows.find(x => String(x.id) === String(itemId));
if (!it) return;
if (it.groupName && !String(it.groupName).includes('客户类型')) {
toast('提示:客户域接线分组为 customer_type,分组「' + it.groupName + '」的项无法被客户引用(接线点位见票 07 表格)', 'info');
}
REF.item = it;
switchTab('ref'); refPaint();
}
function refPrepareItem(it) {
REF.item = it; switchTab('ref'); refPaint();
}
function refPaint() {
const it = REF.item;
$('refInfo').innerHTML = it
? '<b>' + esc(it.name) + '</b>(code=' + esc(it.code) + ',分组=' + esc(it.groupName) + ',referenced=' + esc(it.referenced) + ')'
: '未选择(去「字典项管理」点行内「联测」)';
}
function refLog(line) {
const box = $('refLog');
box.textContent += '\n[' + new Date().toLocaleTimeString() + '] ' + line;
box.scrollTop = box.scrollHeight;
}
async function findDemoItems() {
// 幂等定位(必要时创建)customer_type 分组下的 e2d_type_a / e2d_type_b
const gps = await api('/api/dict/group/page', { method: 'POST', body: qs({ current: 1, size: 200, keyword: REF_DEMO_GROUP }) });
const g = (gps.content || []).find(x => x.code === REF_DEMO_GROUP);
if (!g) throw new Error('分组 customer_type 不存在');
const need = [
{ code: 'e2d_type_a', name: 'e2d-演示类型A', value: 'e2d-演示类型A' },
{ code: 'e2d_type_b', name: 'e2d-演示类型B', value: 'e2d-演示类型B' },
];
const out = {};
for (const n of need) {
const page = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 200, groupId: g.id, keyword: n.code }) });
let it = (page.content || []).find(x => x.code === n.code);
if (it) {
// 重置式幂等:归档客户保留引用计数(数据一致性优先),ref_count 只增不减,
// 直接复用会让「referenced 期望 false」的断言跨轮次漂移——先 force-delete 清计数行再重建
await api('/api/dict/item/force-delete?id=' + it.id, { method: 'POST' });
refLog('force-delete 旧 ' + n.code + '(清残留 ref_count)→ id=' + it.id);
it = null;
}
if (!it) {
await api('/api/dict/item/saveOrUpdate', { method: 'POST', body: qs({ groupId: g.id, name: n.name, value: n.value, code: n.code, sortNo: 999, status: 1, description: 'dict-alignment e2e 演示项(幂等创建)' }) });
const p2 = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 200, groupId: g.id, keyword: n.code }) });
it = (p2.content || []).find(x => x.code === n.code);
refLog('saveOrUpdate 新建 ' + n.code + ' → id=' + (it && it.id));
}
out[n.code] = it;
}
return { group: g, a: out.e2d_type_a, b: out.e2d_type_b };
}
async function refPrepare() {
try {
const { a, b } = await findDemoItems();
if (!a || !b) throw new Error('演示字典项准备失败');
REF.item = a;
refPaint();
$('refOut').innerHTML = '<div class="okbox">演示字典项就绪:e2d_type_a(id=' + a.id + ') / e2d_type_b(id=' + b.id + ')</div>';
loadItems();
} catch (e) { $('refOut').innerHTML = errHtml(e); }
}
async function refCreateCustomer() {
try {
if (!REF.item) throw new Error('请先准备/选择联测字典项');
REF.customerName = 'e2d-引用联测-' + new Date().toISOString().slice(11, 19).replace(/:/g, '');
const d = await api('/api/customer/create', { method: 'POST', body: qs({
customerName: REF.customerName, customerType: REF.item.code,
provinceCode: '440000', cityCode: '440100', industryCode: 'gov',
customerStarLevel: 3, relationStarLevel: 3, isBizNegotiated: 0, isChild: 0,
confirmSimilar: true }) });
if (d && d.needConfirm) throw new Error('相似确认未跳过:' + JSON.stringify(d.similarHits || []).slice(0, 120));
REF.customerId = d && d.id;
const page = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 50, keyword: REF.item.code }) });
const fresh = (page.content || []).find(x => x.code === REF.item.code);
REF.item = fresh || REF.item;
refLog('create customer → id=' + REF.customerId + ',customerType=' + REF.item.code + ',referenced=' + (fresh && fresh.referenced));
refPaint();
$('refOut').innerHTML = '<div class="okbox">客户已创建 id=' + esc(REF.customerId) + ';字典项 referenced=' + esc(REF.item && REF.item.referenced) +
(REF.item && REF.item.referenced ? '(接线生效)' : '(⚠ 期望 true)') + '</div>';
} catch (e) { $('refOut').innerHTML = errHtml(e); }
}
async function refSwapCustomer() {
try {
if (!REF.customerId) throw new Error('请先执行步骤1新建客户');
if (!REF.item) throw new Error('缺少当前项');
const { b } = await findDemoItems();
const det = await api('/api/customer/detail?id=' + REF.customerId);
REF.version = det.version;
await api('/api/customer/edit?id=' + REF.customerId, { method: 'POST', body: qs({
customerName: det.customerName, customerType: b.code,
provinceCode: det.provinceCode, cityCode: det.cityCode, districtCode: det.districtCode,
industryCode: det.industryCode, customerStarLevel: det.customerStarLevel, relationStarLevel: det.relationStarLevel,
isBizNegotiated: det.isBizNegotiated, isChild: det.isChild,
confirmSimilar: true, // 联测演示引用计数随迁,跳过 L2 相似弹窗(弹窗会 needConfirm 短路编辑)
version: det.version }) });
const page = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 50, keyword: 'e2d_type_' }) });
const rows = page.content || [];
const aRow = rows.find(x => x.code === 'e2d_type_a');
const bRow = rows.find(x => x.code === 'e2d_type_b');
REF.item = bRow || REF.item;
refLog('edit swap → ' + REF.item.code + ',a.referenced=' + (aRow && aRow.referenced) + ',b.referenced=' + (bRow && bRow.referenced));
refPaint();
const ok = aRow && bRow && aRow.referenced === false && bRow.referenced === true;
$('refOut').innerHTML = '<div class="' + (ok ? 'okbox' : 'errbox') + '">换引用完成:e2d_type_a.referenced=' + esc(aRow && aRow.referenced) +
'(期望 false,旧值 decrement),e2d_type_b.referenced=' + esc(bRow && bRow.referenced) + '(期望 true,新值 increment)</div>';
} catch (e) { $('refOut').innerHTML = errHtml(e); }
}
async function refDeleteBlocked() {
try {
if (!REF.item) throw new Error('请先选择被引用项');
let blocked = null;
try { await api('/api/dict/item/delete?id=' + REF.item.id, { method: 'POST' }); }
catch (e) { blocked = e; }
if (blocked && blocked.code === 63009) {
refLog('delete → 63009 ' + blocked.message + '(引用阻断生效)');
$('refOut').innerHTML = '<div class="okbox">63009 引用阻断生效:' + esc(blocked.message) + '</div>';
} else if (!blocked) {
refLog('delete → 成功(⚠ 期望 63009 拦截!项可能未被引用)');
$('refOut').innerHTML = '<div class="errbox">删除未被拦截——该项可能没有被引用,先执行步骤1/2</div>';
loadItems();
} else { throw blocked; }
} catch (e) { $('refOut').innerHTML = errHtml(e); }
}
async function refArchive() {
try {
if (!REF.customerId) throw new Error('无演示客户');
await api('/api/customer/archive?id=' + REF.customerId, { method: 'POST' });
refLog('archive customer → id=' + REF.customerId + '(归档保留引用计数)');
$('refOut').innerHTML = '<div class="okbox">演示客户已归档(e2d- 前缀可在客户列表归档视图找到)</div>';
} catch (e) { $('refOut').innerHTML = errHtml(e); }
}
/* ================= 初始化 ================= */
$('apiBox').textContent = 'API: ' + API_BASE;
paintWho();
// 仅已登录才预加载:未登录请求会被 401 拒绝,而安全层响应不带 CORS 头,会刷 console CORS error
if (ME()) loadGroups().then(loadGroupOptions);
</script>
</body>
</html>