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
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
|
|