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.
 
 
 
 
 

1656 lines
103 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>A7 后台管理 × 数据字典 E2E Demo(a7-parity)</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: 2px; padding: 0 16px; background: #fff; border-bottom: 1px solid #e4e7ed; flex-wrap: wrap; }
nav.tabs button { padding: 10px 13px; 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; }
nav.tabs .tsep { flex: 0 0 1px; align-self: stretch; margin: 8px 8px; background: #dcdfe6; }
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; }
.infobox { background: #f4f8ff; border: 1px solid #bfd3f3; color: #2a5a8a; 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; }
/* 展开行(原型 v2:列表只出一级,二级随行内嵌) */
tr.lv2row td { background: #fbfcfe; color: #556; }
tr.lv2row td.name-cell { padding-left: 30px; }
.expander { display: inline-block; width: 16px; height: 16px; line-height: 16px; text-align: center; border: 1px solid #dcdfe6;
border-radius: 3px; cursor: pointer; margin-right: 6px; font-size: 10px; color: #666; background: #fff; user-select: none; }
.expander.on { background: #409eff; color: #fff; border-color: #409eff; }
.childcount { color: #409eff; font-size: 12px; margin-left: 4px; }
.kids { border: 1px solid #ebeef5; border-radius: 6px; padding: 10px; }
.kids .khead { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
.kids table th { padding: 5px 6px; } .kids table td { padding: 4px 6px; white-space: nowrap; }
.kids input[type=text], .kids input:not([type]) { width: 100%; min-width: 90px; }
.kids .delrow { color: #d84a4a; border-color: #f3c1c1; background: #fff; border-radius: 4px; cursor: pointer; padding: 2px 8px; font-size: 12px; }
.kids tr.ghost td { opacity: .45; text-decoration: line-through; }
.kids .vhint { font-size: 11px; color: #999; }
/* 两级树(dict 移植) */
.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: 52px; color: #909399; }
.refbox .step .hint { display: block; }
.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; flex-wrap: wrap; } .kv b { color: #909399; font-weight: 400; min-width: 96px; }
</style>
</head>
<body>
<header>
<h1>A7 后台管理 × 数据字典 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="user" class="on" onclick="switchTab('user')">用户管理</button>
<button data-tab="role" onclick="switchTab('role')">角色管理</button>
<button data-tab="res" onclick="switchTab('res')">权限点管理</button>
<button data-tab="push" onclick="switchTab('push')">方案推送规则</button>
<button data-tab="dedup" onclick="switchTab('dedup')">项目查重规则</button>
<button data-tab="projrule" onclick="switchTab('projrule')">项目规则</button>
<button data-tab="pool" onclick="switchTab('pool')">线索池</button>
<button data-tab="seed" onclick="switchTab('seed')">字典种子验证</button>
<span class="tsep"></span>
<button data-tab="dgroup" onclick="switchTab('dgroup')">分组管理</button>
<button data-tab="ditem" onclick="switchTab('ditem')">字典项管理</button>
<button data-tab="dtree" onclick="switchTab('dtree')">两级树</button>
<button data-tab="dref" onclick="switchTab('dref')">引用计数联测</button>
</nav>
<main>
<!-- ========== Tab 用户管理(票 07 · A7-1) ========== -->
<section id="tab-user">
<div class="panel">
<div class="viewbar">
<b>A7-1 用户统计 / 列表(新入参 username/mobile/title/roleId;出参三时间戳)</b>
<span class="sp"></span>
<button class="act primary" onclick="uStats()">用户统计</button>
</div>
<div id="uStatsOut"></div>
<div class="viewbar" style="margin-top:10px">
<input id="uUsername" placeholder="用户名" style="width:120px">
<input id="uMobile" placeholder="手机号" style="width:130px">
<input id="uTitle" placeholder="职位" style="width:110px">
<button class="act" onclick="loadUsers()">查询</button>
</div>
<table><thead><tr>
<th>用户名</th><th>账号</th><th>职位</th><th>手机号</th><th>主属部门</th><th>角色</th><th>状态</th><th>操作</th>
</tr></thead><tbody id="uBody"></tbody></table>
</div>
<div class="panel refbox">
<b>移交联测(票 07:preview 自移交 61019 / transfer 全域换主 / stats 时间戳)</b>
<div class="hint" style="margin:6px 0 8px">移交方=罗伟健(admin),接收人=曾偲青(BUDDY);步骤3+4 一来一回净零,快照落 sys_user_transfer_log</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="trPreview()">移交预览(admin 名下各域计数)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="trSelf()">自移交拦截(userId=receiverId → 期望 61019)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="trExec()">移交执行 admin→BUDDY(全域计数清零)</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="trBack()">移交返还 BUDDY→admin(计数还原)</button></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="trStatsStamp()">用户统计出参(lastTransferTime 非空)</button></span></div>
</div>
<div id="uTrOut"></div>
<div class="panel"><b>执行日志</b><div id="userLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 角色管理(票 07 · A7-2) ========== -->
<section id="tab-role" style="display:none">
<div class="panel refbox">
<b>角色联测(票 07:stats / 批量增量分配 / copy 停用草稿 / enable 校验 61021 / disable 内置保护)</b>
<div class="step"><b>步骤0</b><span><button class="act" onclick="rolePrepare()">准备演示角色(幂等建 e2d-演示角色)</button></span></div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="roleStats()">角色统计(四计数)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="roleBatchAssign()">批量增量分配给 BUDDY(assign-roles-batch)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="roleCopy()">复制演示角色(期望停用草稿 roleCode 带 _copy_)</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="roleEnableBare()">启用无授权副本(期望 61021 两道校验)</button></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="roleDisableBuiltin()">停用内置管理员角色(期望内置保护拦截)</button></span></div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="roleCleanup()">恢复 BUDDY 角色 + 删除演示角色(在职守卫通路)</button></span></div>
</div>
<div id="roleOut"></div>
<div class="panel"><b>执行日志</b><div id="roleLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 权限点管理(票 07 · A7-2) ========== -->
<section id="tab-res" style="display:none">
<div class="panel refbox">
<b>权限点联测(票 07:references 引用清单 / enable·disable / 禁删两段 61009+61020)</b>
<div class="step"><b>步骤0</b><span><button class="act" onclick="resPrepare()">准备演示权限点 + 禁删链路(演示角色 RD)</button></span></div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="resRefs()">商机管理目录引用清单(期望 ≥1 角色)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="resToggle()">演示点停用→启用 roundtrip</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="resDelBlocked()">删除两段拦截(启用删 61009 / 停用后删 61020)</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="resCleanup()">清理(删 RD 级联清引用 → 停用并删演示点)</button></span></div>
</div>
<div id="resOut"></div>
<div class="panel"><b>执行日志</b><div id="resLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 方案推送规则(票 08) ========== -->
<section id="tab-push" style="display:none">
<div class="panel refbox">
<b>推送规则版本化状态机(8 端点:page/detail/save-draft/publish/copy/disable/delete/versions)</b>
<div class="hint" style="margin:6px 0 8px">
方案卡模板引用自动取「发布中」版本行;嵌套配置走索引属性路径(pushConfig.basic.pushNodeName …);
R1 仅步骤①(D-01 回归:无步骤④发布不 500);R2 完整 4 步骤;残留发布行以 e2d- 前缀停用留存。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="pR1Draft()">新建 R1 草稿(仅步骤①,无步骤④)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="pR1Publish()">发布 R1(D-01 回归:期望成功非 500)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="pR2DraftSave()">新建 R2 完整 4 步骤草稿 + 详情回显</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="pR2Publish()">发布 R2 + versions 版本列表</button></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="pSameName()">同名发布中唯一(期望 64029)</button></span></div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="pBadMode()">创建方式非法(期望 64030)</button></span></div>
<div class="step"><b>步骤7</b><span><button class="act" onclick="pCopy()">复制 R2(新编码 V1.0 草稿)</button></span></div>
<div class="step"><b>步骤8</b><span><button class="act" onclick="pDisable()">停用 R2 + 删除已停用(期望 64027 仅草稿可删)</button></span></div>
<div class="step"><b>步骤9</b><span><button class="act" onclick="pCleanup()">清理草稿(R3/R4/copy)</button></span></div>
</div>
<div id="pushOut"></div>
<div class="panel"><b>执行日志</b><div id="pushLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 项目查重规则(票 09) ========== -->
<section id="tab-dedup" style="display:none">
<div class="panel refbox">
<b>查重规则三档适用范围 + 引用计数联测(票 09 × 票 08 dedup_rule_id 冗余列)</b>
<div class="hint" style="margin:6px 0 8px">
引用计数=引用本版本行的推送规则行数(实时统计、含草稿引用);草稿查重规则不可被推送规则发布引用(64030)。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="dDraftPublish()">新建 D1(全部商机)→ 发布</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="dScopeMissing()">属地档缺适用对象(期望 64031)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="dDefaultScope()">属地档设默认(期望 64031 仅全部商机可设默认)</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="dRefcount()">推送规则 R5 草稿引用 D1 → referenceCount=1(草稿引用入账)</button></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="dDraftRefBlocked()">引用草稿 D2 发布 R6(期望 64030 草稿不可被正式引用)</button></span></div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="dStateMachine()">复制→删草稿 / 停用 D1 / 删已停用(期望 64033)</button></span></div>
<div class="step"><b>步骤7</b><span><button class="act" onclick="dCleanup()">清理(删 R5/R6/D2 草稿,refCount 回落)</button></span></div>
</div>
<div id="dedupOut"></div>
<div class="panel"><b>执行日志</b><div id="dedupLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 项目规则(票 10) ========== -->
<section id="tab-projrule" style="display:none">
<div class="panel refbox">
<b>项目规则单例聚合(GET /current + 5 分区保存 + 64036 校验)</b>
<div class="step"><b>步骤1</b><span><button class="act" onclick="prCurrent()">读当前配置(5 区块)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="prSaveAlias()">保存阶段别名(2 行)→ 回显断言</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="prBadPct()">标底比例越界 150(期望 64036)</button></span></div>
</div>
<div id="projruleOut"></div>
<div class="panel"><b>执行日志</b><div id="projruleLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 线索池(票 10) ========== -->
<section id="tab-pool" style="display:none">
<div class="panel refbox">
<b>线索池(票 10:部门成员三类回填 / 导出 xlsx / 导入 BatchResult)</b>
<div class="step"><b>步骤1</b><span><button class="act" onclick="poolCreate()">建演示池(负责人/协作人/部门成员三类异人)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="poolDetail()">详情回填断言(三类成员名单)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="poolExport()">导出(xlsx 字节数 + ZIP 魔数)</button></span></div>
<div class="step"><b>步骤4</b>
<span>
<input type="file" id="poolImportFile">
<button class="act" onclick="poolImport()">导入上方文件(逐行 BatchResult)</button>
<span class="hint">脚本预先准备 12 列模板 xlsx(1 条 e2d-导入池,部门取空闲第二顺位避让 64003)</span>
</span>
</div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="poolPage()">列表断言(演示池/导入池可见)</button></span></div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="poolCleanup()">清理(删演示池/导入池)</button></span></div>
</div>
<div id="poolOut"></div>
<div class="panel"><b>执行日志</b><div id="poolLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 字典种子验证(票 11) ========== -->
<section id="tab-seed" style="display:none">
<div class="panel refbox">
<b>字典种子验证(票 11:opp_stage 06/07 补值 + opportunity_status 新组 4 值)</b>
<div class="step"><b>步骤1</b><span><button class="act" onclick="dictSeed()">校验种子可见性</button></span></div>
</div>
<div id="seedOut"></div>
<div class="panel"><b>执行日志</b><div id="seedLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 分组管理(dict-alignment 原样移植) ========== -->
<section id="tab-dgroup" style="display:none">
<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 字典项管理(dict-alignment 原样移植) ========== -->
<section id="tab-ditem" 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">
原型 v2 口径(20260908 拍板,dict-alignment 票 08):列表<b>只查一级</b>,二级子项随行内嵌 children(含停用)返回,点行首展开;
筛选只作用一级行。<b>新增/编辑弹窗 children 列表全量替换</b>(带 id 更新 / 不带 id 新建且键值=名称 / 缺失删除,被引用 63009、内置 63003 拦);
复合新增不传键值、后端取字典名称(无子项=单项现状,键值必填 63001);isDefault 一级/子项均可带(同载荷仅一行,同事务自动取消原默认);
<b>最后一个子项</b>请用展开行的「删除」(children 空载荷=单项保存,不触发全量替换)。
其余沿用:编码空自动生成 <code>dict_</code>+UUID、创建后只读(63008);<b>分组默认项禁止停用</b>(63013);
<b>键值被引用后仍可改</b>;未被引用且无子项可换组;删除被引用阻断(63009),force-delete 为兜底。
</div>
</section>
<!-- ========== Tab 两级树(dict-alignment 原样移植) ========== -->
<section id="tab-dtree" 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=(dict-alignment 票 04 增项;走 10s 本地缓存)</span>
</div>
<div id="treeBox" class="tree"><div class="empty">选择分组后加载</div></div>
</div>
</section>
<!-- ========== Tab 引用计数联测(dict-alignment 原样移植) ========== -->
<section id="tab-dref" style="display:none">
<div class="panel refbox">
<b>消费方引用计数联测(dict-alignment 票 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「该字典已被引用,无法删除」</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>
/* ================= 常量与工具(沿 dict-alignment demo 底座) ================= */
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 ADMIN_UID = '739564171091247104'; // 罗伟健(admin)
const BUDDY_UID = '744842318024015872'; // 曾偲青(BUDDY)
const TS = (() => { const q = new URLSearchParams(location.search).get('ts'); if (q) return q;
const d = new Date(); const p = n => String(n).padStart(2, '0');
return '' + p(d.getHours()) + p(d.getMinutes()) + p(d.getSeconds()); })();
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 + ')' : '';
const fmt = v => v == null ? '—' : String(v).replace('T', ' ').slice(0, 19);
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;
}
/* 步骤输出:每个功能页一个 out 容器 + 一个日志容器(box 传裸 id,勿带 #) */
function outBox(box, html) { document.getElementById(box).innerHTML = html; }
function okBox(box, msg) { outBox(box, '<div class="okbox">' + msg + '</div>'); }
function failBox(box, e) { outBox(box, errHtml(e)); }
function logLine(box, line) {
const b = document.getElementById(box);
b.textContent += '\n[' + new Date().toLocaleTimeString() + '] ' + line;
b.scrollTop = b.scrollHeight;
}
/* ================= 登录(沿底座) ================= */
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(); loadUsers();
} 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 === 'ditem') loadGroupOptions(); // 分组可能刚在分组页新建/启停,进字典项页刷新下拉
if (t === 'dtree' && !$('tGroup').options.length) loadGroupOptions().then(loadTree);
}
/* ================= 弹窗(dict 移植) ================= */
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(); }
/* ================= 字典·分组管理(dict-alignment 原样移植) ================= */
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('ditem'); S_I.groupId = String(groupId); S_I.page = 1; $('iGroup').value = S_I.groupId; loadItems();
}
/* ================= 字典·字典项管理(dict-alignment 原样移植) ================= */
const S_I = { page: 1, size: 10, total: 0, groupId: '', rows: [] };
const REF_DEMO_GROUP = 'customer_type'; // 客户域联测分组(dict-alignment 票 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 查询,下拉 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'); }
}
const expanded = new Set();
function toggleExpand(id) { expanded.has(String(id)) ? expanded.delete(String(id)) : expanded.add(String(id)); loadItems(); }
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 => {
const kids = i.children || [];
const open = expanded.has(String(i.id));
const chev = '<span class="expander' + (kids.length ? (open ? ' on' : '') : '') +
'" ' + (kids.length ? 'onclick="toggleExpand(\'' + i.id + '\')" title="展开/收起二级子项"' : 'title="无子项" style="visibility:hidden"') + '>' + (open ? '▼' : '▶') + '</span>';
let html = '<tr>' +
'<td>' + esc(i.groupName) + '</td>' +
'<td class="name-cell">' + chev + esc(i.name) + (kids.length ? '<span class="childcount">' + kids.length + ' 个子项</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>';
if (open) {
html += kids.length ? kids.map(c => '<tr class="lv2row">' +
'<td></td>' +
'<td class="name-cell">└ ' + esc(c.name) + ' <span class="mark">二级</span></td>' +
'<td>' + esc(c.code) + '</td><td>' + esc(c.value) + '</td><td>' + esc(c.sortNo) + '</td>' +
'<td>' + (c.status === 1 ? '<span class="badge b2">启用</span>' : '<span class="badge b4">停用</span>') + '</td>' +
'<td>' + (c.isDefault ? '<span class="badge b5">默认</span>' : '<span class="mark">否</span>') + '</td>' +
'<td>' + (c.referenced ? '<span class="badge b3">被引用</span>' : '') + '</td>' +
'<td class="ops">' +
'<button onclick="dlgItem(\'' + c.id + '\')">编辑</button>' +
'<button onclick="itemStatus(\'' + c.id + '\',\'' + (c.status === 1 ? 0 : 1) + '\')">' + (c.status === 1 ? '停用' : '启用') + '</button>' +
'<button class="warn" onclick="itemDelete(\'' + c.id + '\',\'' + esc(c.code) + '\')">删除</button>' +
'</td></tr>').join('')
: '<tr class="lv2row"><td></td><td colspan="8" class="hint">(无二级子项)</td></tr>';
}
return html;
}).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>'; }
}
// ===== 原型 v2 复合弹窗(children 列表全量替换;一级/二级项编辑同壳) =====
function findItemRow(id) {
for (const t of S_I.rows) {
if (String(t.id) === String(id)) return t;
for (const c of (t.children || [])) if (String(c.id) === String(id)) return c;
}
return null;
}
function kidRowHtml(k) {
const exist = !k._new;
return '<tr data-kid="' + esc(k._tmp || k.id || '') + '" data-new="' + (k._new ? 1 : 0) + '" class="' + (k._del ? 'ghost' : '') + '">' +
'<td><input value="' + esc(k.name) + '" placeholder="必填"></td>' +
'<td><input value="' + esc(k.code) + '" ' + (exist ? 'readonly style="background:#f5f7fa"' : '') + ' placeholder="留空自动生成"></td>' +
'<td style="text-align:center"><input type="checkbox" class="kdef" ' + (k._def ? 'checked' : '') + '></td>' +
'<td><input class="ksort" value="' + esc(k.sortNo == null ? '' : k.sortNo) + '" style="width:56px"></td>' +
'<td class="vhint">' + (exist ? '现值 ' + esc(k.value) + '(更新行键值不变)' : '= 名称(后端落值)') + '</td>' +
'<td>' + (exist ? '<span class="hint">带 id 更新</span> ' : '<span class="hint">新建</span> ') +
'<button class="delrow" onclick="delKidRow(this)">' + (k._del ? '恢复' : '删行') + '</button></td></tr>';
}
function readKidRows() {
const box = $('kidsBody');
if (!box) return [];
return [...box.querySelectorAll('tr')]
.filter(tr => tr.querySelector('input'))
.map(tr => ({
_tmp: tr.dataset.kid,
_new: tr.dataset.new === '1',
name: tr.querySelectorAll('input')[0].value.trim(),
code: tr.querySelectorAll('input')[1].value.trim(),
_def: tr.querySelector('.kdef').checked,
sort: tr.querySelector('.ksort').value.trim(),
_del: tr.classList.contains('ghost'),
}));
}
function repaintKids(kids) {
$('kidsBody').innerHTML = kids.map(kidRowHtml).join('') || '<tr><td colspan="6" class="hint">暂无二级子项,点上方按钮添加</td></tr>';
syncValueField();
}
function addKidRow() {
const kids = readKidRows();
kids.push({ _tmp: 't' + Date.now() + '_' + kids.length, _new: true, name: '', code: '', _def: false, sortNo: kids.filter(k => !k._del).length + 1, _del: false });
repaintKids(kids);
}
function delKidRow(btn) {
const tr = btn.closest('tr');
const kids = readKidRows();
const idx = [...$('kidsBody').querySelectorAll('tr')].indexOf(tr);
kids[idx]._del = !kids[idx]._del;
repaintKids(kids);
}
function syncValueField() {
const v = $('fiValue'); if (!v) return;
const hint = $('fiValueHint');
const it = window._itemOrig ? window._itemOrig : {};
const liveKids = readKidRows().filter(k => !k._del).length;
if (it.parentId != null) {
v.disabled = false;
hint.textContent = '二级项编辑:键值照传(现状口径)';
} else if (it.id) {
v.disabled = false;
hint.textContent = '编辑:键值照传,传了就改;子项新建行不传、后端取名称';
} else if (liveKids > 0) {
v.disabled = true; v.value = '';
v.placeholder = '复合新增:不传键值,后端取字典名称落值';
hint.textContent = '已有 ' + liveKids + ' 个二级子项 → 复合新增:不传键值,后端直接取字典名称落值';
} else {
v.disabled = false;
v.placeholder = '必填,空白报 63001(单项新增现状)';
hint.textContent = '无子项 → 单项新增现状:键值必填(空白 63001)';
}
}
async function dlgItem(id) {
const groups = await api('/api/dict/group/enabled-list').catch(() => []);
let it = {};
if (id) {
it = findItemRow(id);
if (!it) { // 行缓存过期(他页改动):拉一遍一级分页(children 内嵌)再找
const d = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
S_I.rows = d.content || [];
it = findItemRow(id) || {};
}
}
window._itemOrig = it;
const isChild = it.parentId != null;
// 编辑态下拉 = 启用分组 ∪ 当前所属分组(换组仅目标启用组可选;拍板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 gOpts = merged.map(g => '<option value="' + esc(g.id) + '" ' + (String(g.id) === String(it.groupId) ? 'selected' : '') + '>' +
esc(g.name) + (g.code ? '(' + esc(g.code) + ')' : '') + '</option>').join('');
const kids = (!isChild && it.id) ? (it.children || []).map(c => ({ id: c.id, name: c.name, code: c.code, sortNo: c.sortNo, value: c.value, _def: !!c.isDefault })) : [];
dlgOpen(id ? (isChild ? '编辑二级字典项(单项保存;分组随父项锁定)' : '编辑字典项(children 全量替换:带 id 更新 / 不带 id 新建 / 缺失删除)')
: '新建字典项(有子项=复合新增,键值=名称;无子项=单项现状)', `
<label><b>所属分组*</b><select id="fiGroup" ${isChild ? 'disabled' : ''}>${isChild ? `<option>${esc(it.groupName || '')}</option>` : gOpts}</select>
${isChild ? '<span class="hint">二级项随父项归属分组(63008)</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"><span class="hint" id="fiValueHint"></span></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' : ''}><span class="hint">${it.isDefault ? '已是默认项(重勾无副作用)' : '勾选后保存即设默认:同组原默认同事务自动取消;子项行也可在列表里勾选'}</span></label>
<label><b>描述</b><textarea id="fiDesc" rows="2">${esc(it.description)}</textarea></label>
${isChild ? '' : `
<div class="kids">
<div class="khead"><b>二级字典项</b><span class="hint">列表形式随本接口一次提交(children[i].xxx 表单绑定;全量替换)</span>
<span style="flex:1"></span><button class="act" onclick="addKidRow()">+ 添加二级字典项</button></div>
<table><thead><tr><th>字典名称*</th><th>字典编码</th><th>是否默认值</th><th>排序</th><th>键值</th><th></th></tr></thead>
<tbody id="kidsBody">${kids.map(kidRowHtml).join('') || '<tr><td colspan="6" class="hint">暂无二级子项,点上方按钮添加</td></tr>'}</tbody></table>
<div class="hint">「删行」置灰 = 缺失即删除;被引用(63009)/内置(63003)后端拦截。删<b>全部/最后一个</b>子项请关闭弹窗后用展开行的「删除」(children 空载荷=单项保存,不触发替换)。</div>
</div>`}
<div id="fiOut"></div>`,
[{ text: '取消', fn: dlgClose }, { text: '保存', ok: true, fn: () => saveItem(id) }]);
window._itemOrigGroup = it.groupId || '';
syncValueField();
}
async function saveItem(id) {
try {
const it = window._itemOrig || {};
const isChild = it.parentId != null;
const kidsRaw = readKidRows();
const liveKids = kidsRaw.filter(k => !k._del);
if (!isChild && liveKids.length === 0 && kidsRaw.length > 0) {
$('fiOut').innerHTML = '<div class="errbox">子项已全部删行:children 空载荷=单项保存、不会删除子项。请改用展开行的「删除」逐个删,或保留至少一行子项后再保存。</div>';
return;
}
const singleMode = isChild || liveKids.length === 0; // children 空载荷 → 单项保存现状(键值必填走后端 63001)
const body = { groupId: formVal('fiGroup'), name: formVal('fiName'), code: formVal('fiCode'),
sortNo: formVal('fiSort'), status: formVal('fiStatus'), description: formVal('fiDesc') };
if (singleMode) body.value = formVal('fiValue');
if ($('fiDefault').checked && !$('fiDefault').disabled) body.isDefault = 'true';
liveKids.forEach((k, idx) => {
if (k._tmp && !k._new) body['children[' + idx + '].id'] = k._tmp;
body['children[' + idx + '].name'] = k.name;
if (k.code) body['children[' + idx + '].code'] = k.code;
if (k.sort !== '') body['children[' + idx + '].sortNo'] = k.sort;
if (k._def) body['children[' + idx + '].isDefault'] = 'true';
});
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) });
toast('字典项保存成功', 'ok'); dlgClose(); loadItems();
} catch (e) { $('fiOut').innerHTML = errHtml(e); }
}
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');
}
}
/* ================= 字典·两级树(dict-alignment 原样移植) ================= */
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>'; }
}
/* ================= 字典·引用计数联测(dict-alignment 原样移植) ================= */
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 + '」的项无法被客户引用', 'info');
}
REF.item = it;
switchTab('dref'); refPaint();
}
function refPrepareItem(it) {
REF.item = it; switchTab('dref'); 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); }
}
/* ================= A7 · 用户管理(票 07) ================= */
async function uStats() {
try {
const d = await api('/api/system/users/stats');
logLine('userLog', 'stats → ' + JSON.stringify(d));
$('uStatsOut').innerHTML = '<div class="infobox">部门=' + esc(d.deptCount) + ',在职=' + esc(d.activeCount) +
',离职=' + esc(d.resignedCount) + ',未分配=' + esc(d.unassignedCount) +
'\nlastSyncTime=' + esc(fmt(d.lastSyncTime)) + ',lastTransferTime=' + esc(fmt(d.lastTransferTime)) +
',lastTransferUser=' + esc(d.lastTransferUser == null ? '—' : d.lastTransferUser) + '</div>';
} catch (e) { $('uStatsOut').innerHTML = errHtml(e); }
}
async function loadUsers() {
try {
const d = await api('/api/system/users/page', { method: 'POST', body: qs({
current: 1, size: 10, username: $('uUsername').value.trim(), mobile: $('uMobile').value.trim(), title: $('uTitle').value.trim() }) });
const rows = d.content || [];
$('uBody').innerHTML = rows.length ? rows.map(u => '<tr>' +
'<td>' + esc(u.username) + '</td><td>' + esc(u.account) + '</td><td>' + esc(u.title) + '</td><td>' + esc(u.mobile) + '</td>' +
'<td>' + esc(u.primaryDeptName) + '</td>' +
'<td>' + (u.roles || []).map(r => '<span class="badge b1">' + esc(r.name) + '</span>').join('') + '</td>' +
'<td>' + (u.enabled ? '<span class="badge b2">启用</span>' : '<span class="badge b4">停用</span>') + '</td>' +
'<td class="ops"><button onclick="$(\'uUsername\').value=\'' + esc(u.username) + '\';loadUsers()">按名筛</button></td></tr>').join('')
: '<tr><td colspan="8" class="empty">无匹配用户</td></tr>';
} catch (e) { $('uBody').innerHTML = '<tr><td colspan="8" class="errbox">' + esc(e.message) + '</td></tr>'; }
}
/* --- 移交联测 --- */
const TR = { baseCounts: null, buddyBase: null };
async function trPreview() {
try {
const d = await api('/api/system/users/transfer-preview?id=' + ADMIN_UID);
TR.baseCounts = d;
logLine('userLog', 'preview → ' + JSON.stringify(d));
okBox('uTrOut', '移交预览就绪:' + d.map(x => x.domainName + '=' + x.count).join(',') +
'\n(快照已存,步骤4 返还后与此对照)');
} catch (e) { failBox('uTrOut', e); }
}
async function trSelf() {
try {
await api('/api/system/users/transfer?userId=' + ADMIN_UID + '&receiverId=' + ADMIN_UID, { method: 'POST' });
failBox('uTrOut', new Error('自移交未被拦截'));
} catch (e) {
if (e.code === 61019) okBox('uTrOut', '61019 自移交拦截生效:' + esc(e.message));
else failBox('uTrOut', e);
}
}
async function trExec() {
try {
TR.buddyBase = await api('/api/system/users/transfer-preview?id=' + BUDDY_UID);
const d = await api('/api/system/users/transfer?userId=' + ADMIN_UID + '&receiverId=' + BUDDY_UID, { method: 'POST' });
logLine('userLog', 'transfer → ' + JSON.stringify(d));
const pre = await api('/api/system/users/transfer-preview?id=' + ADMIN_UID);
const allZero = (pre || []).every(x => Number(x.count) === 0);
TR.buddyGot = (d.domainCounts || []).map(x => x.domainName + '=' + x.count).join(',');
if (allZero) okBox('uTrOut', '移交执行成功:admin 名下清零;BUDDY 收到 ' + TR.buddyGot + '\nsnapshot transferTime=' + esc(fmt(d.transferTime)));
else failBox('uTrOut', new Error('移交后 admin 仍有残留:' + JSON.stringify(pre)));
} catch (e) { failBox('uTrOut', e); }
}
async function trBack() {
try {
const d = await api('/api/system/users/transfer?userId=' + BUDDY_UID + '&receiverId=' + ADMIN_UID, { method: 'POST' });
// BUDDY 净零:名下计数回落到移交前基数(BUDDY 自有资产不在移交断言内逐域等值卡死)
const preB = await api('/api/system/users/transfer-preview?id=' + BUDDY_UID);
const baseB = TR.buddyBase || [];
const buddyNetZero = baseB.length && baseB.every(b => {
const now = (preB || []).find(x => x.domain === b.domain);
return now && Number(now.count) === Number(b.count);
});
const preA = await api('/api/system/users/transfer-preview?id=' + ADMIN_UID);
const baseA = TR.baseCounts || [];
const adminBack = baseA.length && baseA.every(b => {
const now = (preA || []).find(x => x.domain === b.domain);
return now && Number(now.count) >= Number(b.count);
});
if (buddyNetZero && adminBack) okBox('uTrOut', '返还成功:BUDDY 名下回落至移交前(' + baseB.map(x => x.domainName + '=' + x.count).join(',') + ');admin 各域 ≥ 原快照');
else failBox('uTrOut', new Error('返还断言不符:buddyBase=' + JSON.stringify(baseB) + ' buddyNow=' + JSON.stringify(preB) + ' adminNow=' + JSON.stringify(preA)));
} catch (e) { failBox('uTrOut', e); }
}
async function trStatsStamp() {
try {
const d = await api('/api/system/users/stats');
const okStamp = d.lastTransferTime != null;
okBox('uTrOut', (okStamp ? '出参时间戳到位' : '⚠ lastTransferTime 仍为空') +
'\nlastTransferTime=' + esc(fmt(d.lastTransferTime)) + ',lastTransferUser=' + esc(d.lastTransferUser == null ? '—' : d.lastTransferUser) +
'\nlastSyncTime=' + esc(fmt(d.lastSyncTime)) + '(钉钉同步前可为空)');
} catch (e) { failBox('uTrOut', e); }
}
/* ================= A7 · 角色管理(票 07) ================= */
const R = { demoId: null, copyId: null, buddyBaseRoles: '' };
async function findRoleByName(name) {
const d = await api('/api/roles/page', { method: 'POST', body: qs({ current: 1, size: 100, roleName: name }) });
return (d.content || []).find(x => x.roleName === name) || null;
}
async function rolePrepare() {
try {
const name = 'e2d-演示角色-' + TS;
// 幂等:先清掉历史 e2d-演示角色(仅删得到草稿/无引用角色),再新建
const pg = await api('/api/roles/page', { method: 'POST', body: qs({ current: 1, size: 100, roleName: 'e2d-演示角色' }) });
for (const old of (pg.content || []).filter(x => (x.roleName || '').startsWith('e2d-演示角色'))) {
try { await api('/api/roles/delete?id=' + old.id, { method: 'POST' }); logLine('roleLog', 'pre-clean 删旧角色 ' + old.roleName); } catch (e) {}
}
// 记录 BUDDY 当前角色(还原基准)
const up = await api('/api/system/users/page', { method: 'POST', body: qs({ current: 1, size: 5, username: '曾偲青' }) });
const buddy = (up.content || []).find(x => String(x.id) === BUDDY_UID);
R.buddyBaseRoles = buddy ? (buddy.roles || []).map(r => r.id).join(',') : '';
await api('/api/roles/saveOrUpdate', { method: 'POST', body: qs({ roleName: name, roleCode: 'e2d_role_' + TS, remark: 'a7-parity e2e' }) });
const created = await findRoleByName(name);
if (!created) throw new Error('演示角色创建后查不到');
R.demoId = created.id;
logLine('roleLog', '演示角色 id=' + R.demoId + ';BUDDY 基准角色=[' + R.buddyBaseRoles + ']');
okBox('roleOut', '演示角色就绪:' + esc(name) + '(id=' + esc(created.id) + ',status=' + esc(created.status) + ')');
} catch (e) { failBox('roleOut', e); }
}
async function roleStats() {
try {
const d = await api('/api/roles/stats');
const keys = ['roleCount', 'enabledCount', 'disabledCount', 'zeroUsageCount'];
const missing = keys.filter(k => d[k] === undefined);
if (missing.length) failBox('roleOut', new Error('stats 出参缺字段: ' + missing.join(',')));
else okBox('roleOut', '角色统计:总数=' + esc(d.roleCount) + ',启用=' + esc(d.enabledCount) + ',停用=' + esc(d.disabledCount) + ',零使用=' + esc(d.zeroUsageCount));
} catch (e) { failBox('roleOut', e); }
}
async function roleBatchAssign() {
try {
if (!R.demoId) throw new Error('先执行步骤0');
await api('/api/system/users/assign-roles-batch?userIds=' + BUDDY_UID + '&roleIds=' + R.demoId, { method: 'POST' });
const up = await api('/api/system/users/page', { method: 'POST', body: qs({ current: 1, size: 5, username: '曾偲青' }) });
const buddy = (up.content || []).find(x => String(x.id) === BUDDY_UID);
const has = buddy && (buddy.roles || []).some(r => String(r.id) === String(R.demoId));
if (has) okBox('roleOut', '批量增量分配生效:BUDDY roles 含演示角色(id=' + esc(R.demoId) + ')');
else failBox('roleOut', new Error('批量分配后 BUDDY roles 未命中演示角色: ' + JSON.stringify(buddy && buddy.roles)));
} catch (e) { failBox('roleOut', e); }
}
async function roleCopy() {
try {
if (!R.demoId) throw new Error('先执行步骤0');
const d = await api('/api/roles/copy?id=' + R.demoId, { method: 'POST' });
R.copyId = d.id;
const isDraft = d.status === 'disabled';
const codeOk = (d.roleCode || '').includes('_copy_');
if (isDraft && codeOk) okBox('roleOut', '复制产出停用草稿:roleName=' + esc(d.roleName) + ',roleCode=' + esc(d.roleCode) + ',status=' + esc(d.status));
else failBox('roleOut', new Error('复制语义不符: status=' + d.status + ' roleCode=' + d.roleCode));
} catch (e) { failBox('roleOut', e); }
}
async function roleEnableBare() {
try {
if (!R.copyId) throw new Error('先执行步骤3');
await api('/api/roles/enable?id=' + R.copyId, { method: 'POST' });
failBox('roleOut', new Error('无授权角色启用未被拦截'));
} catch (e) {
if (e.code === 61021) okBox('roleOut', '61021 启用校验生效:' + esc(e.message));
else failBox('roleOut', e);
}
}
async function roleDisableBuiltin() {
try {
const pg = await api('/api/roles/page', { method: 'POST', body: qs({ current: 1, size: 50 }) });
const builtin = (pg.content || []).find(x => x.builtin);
if (!builtin) throw new Error('找不到内置角色');
await api('/api/roles/disable?id=' + builtin.id, { method: 'POST' });
failBox('roleOut', new Error('内置角色停用未被拦截'));
} catch (e) {
if (/内置/.test(e.message || '')) okBox('roleOut', '内置角色保护生效:' + esc(e.message) + codeSuffix(e));
else failBox('roleOut', e);
}
}
async function roleCleanup() {
try {
// 还原 BUDDY 角色集(全量替换回基准),再删演示角色与副本
await api('/api/system/users/assign-roles?userId=' + BUDDY_UID + '&roleIds=' + R.buddyBaseRoles, { method: 'POST' });
let removed = [];
for (const id of [R.demoId, R.copyId].filter(Boolean)) {
try { await api('/api/roles/delete?id=' + id, { method: 'POST' }); removed.push(id); }
catch (e) { logLine('roleLog', '删角色 ' + id + ' 失败: ' + e.message + codeSuffix(e)); }
}
okBox('roleOut', 'BUDDY 角色已还原(基准=[' + esc(R.buddyBaseRoles || '无') + ']);删除角色 ' + removed.length + '/2 个' +
(removed.length < 2 ? '(失败项见日志)' : ''));
} catch (e) { failBox('roleOut', e); }
}
/* ================= A7 · 权限点管理(票 07) ================= */
const RES = { demoId: null, roleId: null, bizCatalogId: null };
async function resPrepare() {
try {
const list = await api('/api/resources/list');
// 幂等清理历史演示点
const walk = nodes => { for (const n of (nodes || [])) { if ((n.name || '').startsWith('e2d-演示权限点')) { RES.demoId = n.id; } walk(n.children); } };
walk(list);
if (RES.demoId) { try { await api('/api/resources/delete?id=' + RES.demoId, { method: 'POST' }); } catch (e) {} }
// 找第一个 menu 节点作父
let parent = null, biz = null;
const scan = nodes => { for (const n of (nodes || [])) {
if (n.type === 'CATALOG' && n.name === '商机管理') biz = n;
if (!parent && n.type === 'MENU') parent = n;
scan(n.children);
} };
scan(list);
RES.parentMenuId = parent && parent.id; RES.bizCatalogId = biz && biz.id;
if (!RES.parentMenuId) throw new Error('资源树中找不到 menu 节点');
const d = await api('/api/resources/saveOrUpdate', { method: 'POST', body: qs({
parentId: RES.parentMenuId, type: 'BUTTON', name: 'e2d-演示权限点-' + TS,
perms: 'e2d:demo:' + TS, denyBehavior: 'HIDE', apiUrl: '/api/e2d/demo:' + TS,
sort: 9999, status: 'enabled', description: 'a7-parity e2e' }) });
RES.demoId = d && d.id;
RES.roleId = null;
// 确定性 61020 链路:建演示角色 RD → 授权演示点 → 停用 RD。
// 资源停用仅拦「启用中角色」引用;删除第③段拦任何角色引用(61020)——引用方为停用角色才能先停资源再删撞 61020。
const rname = 'e2d-资源演示角色-' + TS;
const rpg = await api('/api/roles/page', { method: 'POST', body: qs({ current: 1, size: 100, roleName: rname }) });
for (const old of (rpg.content || []).filter(x => x.roleName === rname)) {
try { await api('/api/roles/delete?id=' + old.id, { method: 'POST' }); } catch (e) {}
}
await api('/api/roles/saveOrUpdate', { method: 'POST', body: qs({ roleName: rname, roleCode: 'e2d_rrole_' + TS, remark: 'a7-parity e2e' }) });
const rcreated = await findRoleByName(rname);
RES.roleId = rcreated.id;
await api('/api/roles/assign-resources?roleId=' + RES.roleId + '&resourceIds=' + RES.demoId, { method: 'POST' });
await api('/api/roles/disable?id=' + RES.roleId, { method: 'POST' });
okBox('resOut', '演示权限点就绪:id=' + esc(RES.demoId) + '(父 menu=' + esc(RES.parentMenuId) + ');禁删链路就绪:演示角色 RD=' +
esc(RES.roleId) + ' 已授权并停用');
} catch (e) { failBox('resOut', e); }
}
async function resRefs() {
try {
if (!RES.bizCatalogId) throw new Error('先执行步骤0(记录目录 id)');
const d = await api('/api/resources/references?id=' + RES.bizCatalogId);
if ((d || []).length >= 1) okBox('resOut', '引用清单非空:' + d.map(x => x.roleName + '(在职' + x.activeUserCount + ')').join('、'));
else failBox('resOut', new Error('商机管理目录引用清单为空,期望 ≥1'));
} catch (e) { failBox('resOut', e); }
}
async function resToggle() {
try {
if (!RES.demoId) throw new Error('先执行步骤0');
await api('/api/resources/disable?id=' + RES.demoId, { method: 'POST' });
await api('/api/resources/enable?id=' + RES.demoId, { method: 'POST' });
okBox('resOut', '演示点 停用→启用 roundtrip 成功(幂等)');
} catch (e) { failBox('resOut', e); }
}
async function resDelBlocked() {
try {
if (!RES.demoId) throw new Error('先执行步骤0');
// 第一段:启用中资源删除 → 61009 仅停用可删
let firstMsg = '';
try {
await api('/api/resources/delete?id=' + RES.demoId, { method: 'POST' });
failBox('resOut', new Error('启用中资源删除未被拦截')); return;
} catch (e) {
if (e.code !== 61009 || !/仅停用/.test(e.message || '')) { failBox('resOut', e); return; }
firstMsg = e.message;
}
// 停用资源(引用方 RD 已停用 → 放行),再删 → 第③段 61020
await api('/api/resources/disable?id=' + RES.demoId, { method: 'POST' });
try {
await api('/api/resources/delete?id=' + RES.demoId, { method: 'POST' });
failBox('resOut', new Error('被引用资源删除未被拦截'));
} catch (e) {
if (e.code === 61020) okBox('resOut', '禁删两段生效:启用中删除=61009(' + esc(firstMsg) + ');停用后删除=61020(' + esc(e.message) + ')');
else failBox('resOut', e);
}
} catch (e) { failBox('resOut', e); }
}
async function resCleanup() {
try {
if (!RES.demoId) throw new Error('先执行步骤0');
if (RES.roleId) { try { await api('/api/roles/delete?id=' + RES.roleId, { method: 'POST' }); } catch (e) { logLine('resLog', '删 RD: ' + e.message); } }
await api('/api/resources/disable?id=' + RES.demoId, { method: 'POST' });
await api('/api/resources/delete?id=' + RES.demoId, { method: 'POST' });
okBox('resOut', '演示角色 RD 已删(级联清引用);演示权限点已停用并删除');
} catch (e) { failBox('resOut', e); }
}
/* ================= A7 · 方案推送规则(票 08) ================= */
const P = { schemeTplId: null, r1: null, r2: null, r3: null, r4: null, copyId: null };
async function schemeTplId() {
if (P.schemeTplId) return P.schemeTplId;
const d = await api('/api/rule/opp-scheme-template/page', { method: 'POST', body: qs({ current: 1, size: 10, status: 2 }) });
const row = (d.content || []).find(x => Number(x.status) === 2);
if (!row) throw new Error('没有发布中的方案卡模板版本');
P.schemeTplId = row.id;
logLine('pushLog', '方案卡模板发布中版本 id=' + P.schemeTplId + '(' + row.templateName + ')');
return P.schemeTplId;
}
async function pushFindByName(name) {
const d = await api('/api/rule/opp-push-rule/page', { method: 'POST', body: qs({ current: 1, size: 50, ruleName: name }) });
return (d.content || []).filter(x => x.ruleName === name);
}
async function pushPreClean() {
for (const name of ['e2d-推送R1-' + TS, 'e2d-推送R2-' + TS, 'e2d-推送R3-' + TS, 'e2d-推送R4-' + TS, 'e2d-推送R5-' + TS, 'e2d-推送R6-' + TS]) {
for (const row of await pushFindByName(name)) {
try { await api('/api/rule/opp-push-rule/delete?id=' + row.id, { method: 'POST' }); logLine('pushLog', 'pre-clean 删草稿 ' + row.ruleCode); } catch (e) {}
}
}
}
async function pR1Draft() {
try {
await pushPreClean();
const tpl = await schemeTplId();
await api('/api/rule/opp-push-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-推送R1-' + TS, 'applyScope': 1, 'isDefault': 0, 'ruleDesc': 'D-01 回归:无步骤④',
'pushConfig.basic.pushNodeName': '方案卡确认',
'pushConfig.basic.schemeTemplateId': String(tpl) }) });
const rows = await pushFindByName('e2d-推送R1-' + TS);
if (!rows.length) throw new Error('R1 草稿创建后查不到');
P.r1 = rows[0];
okBox('pushOut', 'R1 草稿就绪:id=' + esc(P.r1.id) + ',编码=' + esc(P.r1.ruleCode) + ',版本=' + esc(P.r1.versionNo));
} catch (e) { failBox('pushOut', e); }
}
async function pR1Publish() {
try {
if (!P.r1) throw new Error('先执行步骤1');
await api('/api/rule/opp-push-rule/publish', { method: 'POST', body: qs({
'id': String(P.r1.id), 'ruleName': P.r1.ruleName, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': '方案卡确认',
'pushConfig.basic.schemeTemplateId': String(P.schemeTplId) }) });
const rows = await pushFindByName(P.r1.ruleName);
const st = rows.length && rows[0].status;
if (Number(st) === 2) okBox('pushOut', 'D-01 修复验证:无步骤④发布成功(status=2 发布中)');
else failBox('pushOut', new Error('发布后状态异常: ' + st));
} catch (e) {
if (e.message && /系统内部错误/.test(e.message)) failBox('pushOut', new Error('D-01 未修复:发布 500 系统内部错误'));
else failBox('pushOut', e);
}
}
async function pR2DraftSave() {
try {
const tpl = await schemeTplId();
await api('/api/rule/opp-push-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-推送R2-' + TS, 'applyScope': 2, 'conditionType': 'PROJECT_STAGE', 'conditionValue': '投标阶段',
'isDefault': 0, 'ruleDesc': '完整 4 步骤',
'pushConfig.basic.pushNodeName': '方案评审',
'pushConfig.basic.schemeTemplateId': String(tpl),
'pushConfig.fieldValidations[0].fieldName': '项目名称',
'pushConfig.fieldValidations[0].fieldSource': '商机',
'pushConfig.fieldValidations[0].fieldType': 'TEXT_SINGLE',
'pushConfig.fieldValidations[0].validateRule': '非空',
'pushConfig.fieldValidations[0].failAction': '阻断提交',
'pushConfig.fieldMappings[0].fieldName': '客户名称',
'pushConfig.fieldMappings[0].targetField': 'customer_name',
'pushConfig.fieldMappings[0].pushMode': '覆盖',
'pushConfig.projectCreation.createMode': 'auto',
'pushConfig.projectCreation.nameRule': '{客户名称}-项目' }) });
const rows = await pushFindByName('e2d-推送R2-' + TS);
if (!rows.length) throw new Error('R2 草稿创建后查不到');
P.r2 = rows[0];
const det = await api('/api/rule/opp-push-rule/detail?id=' + P.r2.id);
const pc = det.pushConfig || {};
const okEcho = pc.basic && pc.basic.pushNodeName === '方案评审'
&& (pc.fieldValidations || []).length === 1 && pc.fieldValidations[0].validateRule === '非空'
&& (pc.fieldMappings || []).length === 1 && pc.fieldMappings[0].targetField === 'customer_name'
&& pc.projectCreation && pc.projectCreation.createMode === 'auto';
if (okEcho) okBox('pushOut', 'R2 草稿 4 步骤回显一致:basic=' + esc(pc.basic.pushNodeName) +
',校验行=' + pc.fieldValidations.length + ',映射行=' + pc.fieldMappings.length +
',createMode=' + esc(pc.projectCreation.createMode) + ',applyScope=2 条件值=' + esc(det.conditionValue));
else failBox('pushOut', new Error('详情回显不符: ' + JSON.stringify(pc).slice(0, 200)));
} catch (e) { failBox('pushOut', e); }
}
async function pR2Publish() {
try {
if (!P.r2) throw new Error('先执行步骤3');
await api('/api/rule/opp-push-rule/publish', { method: 'POST', body: qs({
'id': String(P.r2.id), 'ruleName': P.r2.ruleName, 'applyScope': 2, 'conditionType': 'PROJECT_STAGE',
'conditionValue': '投标阶段', 'isDefault': 0,
'pushConfig.basic.pushNodeName': '方案评审',
'pushConfig.basic.schemeTemplateId': String(P.schemeTplId),
'pushConfig.projectCreation.createMode': 'auto' }) });
const ver = await api('/api/rule/opp-push-rule/versions?id=' + P.r2.id);
const cur = (ver || []).find(v => String(v.id) === String(P.r2.id));
if (cur && Number(cur.status) === 2) okBox('pushOut', 'R2 发布成功:versions ' + ver.length + ' 行,当前 ' + esc(cur.versionNo) + ' status=2 发布中');
else failBox('pushOut', new Error('versions 状态异常: ' + JSON.stringify(ver).slice(0, 150)));
} catch (e) { failBox('pushOut', e); }
}
async function pSameName() {
try {
if (!P.r2) throw new Error('先执行步骤4');
const tpl = await schemeTplId();
await api('/api/rule/opp-push-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-推送R3-' + TS, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl) }) });
// 把 R3 改名为 R2 已发布名再发布 → 64029
const r3rows = await pushFindByName('e2d-推送R3-' + TS);
P.r3 = r3rows[0];
if (!P.r3) throw new Error('R3 草稿创建失败');
await api('/api/rule/opp-push-rule/publish', { method: 'POST', body: qs({
'id': String(P.r3.id), 'ruleName': P.r2.ruleName, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl),
'pushConfig.projectCreation.createMode': 'auto' }) });
failBox('pushOut', new Error('同名发布中未被拦截'));
} catch (e) {
if (e.code === 64029) okBox('pushOut', '64029 同名称发布中唯一生效:' + esc(e.message));
else failBox('pushOut', e);
}
}
async function pBadMode() {
try {
const tpl = await schemeTplId();
await api('/api/rule/opp-push-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-推送R4-' + TS, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl) }) });
const rows = await pushFindByName('e2d-推送R4-' + TS);
P.r4 = rows[0];
if (!P.r4) throw new Error('R4 草稿创建失败');
await api('/api/rule/opp-push-rule/publish', { method: 'POST', body: qs({
'id': String(P.r4.id), 'ruleName': P.r4.ruleName, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl),
'pushConfig.projectCreation.createMode': 'bogus' }) });
failBox('pushOut', new Error('非法创建方式未被拦截'));
} catch (e) {
if (e.code === 64030) okBox('pushOut', '64030 创建方式值域校验生效:' + esc(e.message));
else failBox('pushOut', e);
}
}
async function pCopy() {
try {
if (!P.r2) throw new Error('先执行步骤4');
const newId = await api('/api/rule/opp-push-rule/copy?id=' + P.r2.id, { method: 'POST' });
P.copyId = newId;
const det = await api('/api/rule/opp-push-rule/detail?id=' + newId);
const isNewCode = det.ruleCode !== P.r2.ruleCode;
const isDraftV1 = Number(det.status) === 1 && det.versionNo === 'V1.0';
if (isNewCode && isDraftV1) okBox('pushOut', '复制成功:新编码=' + esc(det.ruleCode) + ',V1.0 草稿(id=' + esc(newId) + ')');
else failBox('pushOut', new Error('复制语义不符: code=' + det.ruleCode + ' status=' + det.status + ' ver=' + det.versionNo));
} catch (e) { failBox('pushOut', e); }
}
async function pDisable() {
try {
if (!P.r2) throw new Error('先执行步骤4');
await api('/api/rule/opp-push-rule/disable?id=' + P.r2.id, { method: 'POST' });
let blockedMsg = '';
try { await api('/api/rule/opp-push-rule/delete?id=' + P.r2.id, { method: 'POST' }); }
catch (e) { blockedMsg = e.code + ' ' + e.message; }
if (blockedMsg.startsWith('64027')) okBox('pushOut', '停用成功 + 仅草稿可删拦截生效(' + esc(blockedMsg) + ')');
else failBox('pushOut', new Error('停用后删除未被拦截: ' + blockedMsg));
} catch (e) { failBox('pushOut', e); }
}
async function pCleanup() {
try {
let n = 0;
for (const id of [P.r3 && P.r3.id, P.r4 && P.r4.id, P.copyId].filter(Boolean)) {
try { await api('/api/rule/opp-push-rule/delete?id=' + id, { method: 'POST' }); n++; } catch (e) { logLine('pushLog', '清 ' + id + ': ' + e.message); }
}
okBox('pushOut', '清理草稿 ' + n + '/3(R1/R2 已发布转停用留存,e2d- 前缀可辨)');
} catch (e) { failBox('pushOut', e); }
}
/* ================= A7 · 项目查重规则(票 09) ================= */
const D = { d1: null, d2: null, r5: null, r6: null };
async function dedupFindByName(name) {
const d = await api('/api/rule/opp-dedup-rule/page', { method: 'POST', body: qs({ current: 1, size: 50, ruleName: name }) });
return (d.content || []).filter(x => x.ruleName === name);
}
async function dedupPreClean() {
for (const name of ['e2d-查重D1-' + TS, 'e2d-查重D2-' + TS]) {
for (const row of await dedupFindByName(name)) {
try { await api('/api/rule/opp-dedup-rule/delete?id=' + row.id, { method: 'POST' }); logLine('dedupLog', 'pre-clean 删草稿 ' + row.ruleCode); } catch (e) {}
}
}
}
async function dDraftPublish() {
try {
await dedupPreClean();
await api('/api/rule/opp-dedup-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-查重D1-' + TS, 'applyScopeType': 1, 'isDefault': 0, 'ruleDesc': 'a7 e2e',
'wizardConfig.triggerScenarios[0]': 'OPP_TO_PROJECT',
'wizardConfig.searchConditions[0].field': '项目名称',
'wizardConfig.searchConditions[0].matchMode': 'SIMILAR',
'wizardConfig.searchConditions[0].threshold': '80' }) });
const rows = await dedupFindByName('e2d-查重D1-' + TS);
if (!rows.length) throw new Error('D1 草稿创建后查不到');
D.d1 = rows[0];
await api('/api/rule/opp-dedup-rule/publish', { method: 'POST', body: qs({
'id': String(D.d1.id), 'ruleName': D.d1.ruleName, 'applyScopeType': 1, 'isDefault': 0,
'wizardConfig.triggerScenarios[0]': 'OPP_TO_PROJECT' }) });
const det = await api('/api/rule/opp-dedup-rule/detail?id=' + D.d1.id);
const rc = det.referenceCount == null ? '缺字段' : det.referenceCount;
if (Number(det.status) === 2 && Number(rc) === 0) okBox('dedupOut', 'D1 发布成功:status=2,referenceCount=' + esc(rc) + '(出参字段在位)');
else failBox('dedupOut', new Error('D1 状态/出参异常: status=' + det.status + ' refCount=' + rc));
} catch (e) { failBox('dedupOut', e); }
}
async function dScopeMissing() {
try {
await api('/api/rule/opp-dedup-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-查重缺对象-' + TS, 'applyScopeType': 2, 'isDefault': 0 }) });
failBox('dedupOut', new Error('属地档缺适用对象未被拦截'));
} catch (e) {
if (e.code === 64031) okBox('dedupOut', '64031 属地档校验生效:' + esc(e.message));
else failBox('dedupOut', e);
}
}
async function dDefaultScope() {
try {
await api('/api/rule/opp-dedup-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-查重默认-' + TS, 'applyScopeType': 2, 'applyScopeValue': '南京公司', 'isDefault': 1 }) });
failBox('dedupOut', new Error('属地档设默认未被拦截'));
} catch (e) {
if (e.code === 64031) okBox('dedupOut', '64031 仅全部商机可设默认生效:' + esc(e.message));
else failBox('dedupOut', e);
}
}
async function dRefcount() {
try {
if (!D.d1) throw new Error('先执行步骤1');
const tpl = await schemeTplId();
await api('/api/rule/opp-push-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-推送R5-' + TS, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl),
'pushConfig.projectCreation.createMode': 'auto',
'pushConfig.projectCreation.dedupRuleId': String(D.d1.id) }) });
const rows = await pushFindByName('e2d-推送R5-' + TS);
D.r5 = rows[0];
if (!D.r5) throw new Error('R5 草稿创建失败');
const det = await api('/api/rule/opp-dedup-rule/detail?id=' + D.d1.id);
const ver = await api('/api/rule/opp-dedup-rule/versions?id=' + D.d1.id);
const v1 = (ver || []).find(v => String(v.id) === String(D.d1.id));
if (Number(det.referenceCount) >= 1 && v1 && Number(v1.referenceCount) >= 1)
okBox('dedupOut', '引用计数入账:detail.referenceCount=' + esc(det.referenceCount) + ',versions ' + esc(v1.versionNo) + ' referenceCount=' + esc(v1.referenceCount) + '(草稿引用也计入)');
else failBox('dedupOut', new Error('引用计数未入账: detail=' + det.referenceCount + ' versions=' + (v1 && v1.referenceCount)));
} catch (e) { failBox('dedupOut', e); }
}
async function dDraftRefBlocked() {
try {
if (!D.d1) throw new Error('先执行步骤1');
const tpl = await schemeTplId();
await api('/api/rule/opp-dedup-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-查重D2-' + TS, 'applyScopeType': 1, 'isDefault': 0 }) });
const rows = await dedupFindByName('e2d-查重D2-' + TS);
D.d2 = rows[0];
if (!D.d2) throw new Error('D2 草稿创建失败');
await api('/api/rule/opp-push-rule/save-draft', { method: 'POST', body: qs({
'ruleName': 'e2d-推送R6-' + TS, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl),
'pushConfig.projectCreation.createMode': 'auto',
'pushConfig.projectCreation.dedupRuleId': String(D.d2.id) }) });
const r6rows = await pushFindByName('e2d-推送R6-' + TS);
D.r6 = r6rows[0];
if (!D.r6) throw new Error('R6 草稿创建失败');
await api('/api/rule/opp-push-rule/publish', { method: 'POST', body: qs({
'id': String(D.r6.id), 'ruleName': D.r6.ruleName, 'applyScope': 1, 'isDefault': 0,
'pushConfig.basic.pushNodeName': 'x', 'pushConfig.basic.schemeTemplateId': String(tpl),
'pushConfig.projectCreation.createMode': 'auto',
'pushConfig.projectCreation.dedupRuleId': String(D.d2.id) }) });
failBox('dedupOut', new Error('引用草稿发布未被拦截'));
} catch (e) {
if (e.code === 64030 && /草稿不可被正式引用/.test(e.message || '')) okBox('dedupOut', '64030 草稿查重规则引用拦截生效:' + esc(e.message));
else failBox('dedupOut', e);
}
}
async function dStateMachine() {
try {
if (!D.d1) throw new Error('先执行步骤1');
const copyId = await api('/api/rule/opp-dedup-rule/copy?id=' + D.d1.id, { method: 'POST' });
const copyDet = await api('/api/rule/opp-dedup-rule/detail?id=' + copyId);
const copyOk = copyDet.ruleCode !== D.d1.ruleCode && Number(copyDet.status) === 1 && copyDet.versionNo === 'V1.0';
await api('/api/rule/opp-dedup-rule/delete?id=' + copyId, { method: 'POST' });
await api('/api/rule/opp-dedup-rule/disable?id=' + D.d1.id, { method: 'POST' });
let blockedMsg = '';
try { await api('/api/rule/opp-dedup-rule/delete?id=' + D.d1.id, { method: 'POST' }); }
catch (e) { blockedMsg = e.code + ' ' + e.message; }
if (copyOk && blockedMsg.startsWith('64033'))
okBox('dedupOut', '复制(新编码 V1.0 草稿)→ 草稿可删 ✓;停用 D1 → 删除被拦(' + esc(blockedMsg) + ')');
else failBox('dedupOut', new Error('状态机断言不符: copyOk=' + copyOk + ' blocked=' + blockedMsg));
} catch (e) { failBox('dedupOut', e); }
}
async function dCleanup() {
try {
let n = 0;
for (const r of [D.r5, D.r6].filter(Boolean)) {
try { await api('/api/rule/opp-push-rule/delete?id=' + r.id, { method: 'POST' }); n++; } catch (e) { logLine('dedupLog', '清 R: ' + e.message); }
}
if (D.d2) { try { await api('/api/rule/opp-dedup-rule/delete?id=' + D.d2.id, { method: 'POST' }); n++; } catch (e) { logLine('dedupLog', '清 D2: ' + e.message); } }
let after = '';
if (D.d1) {
const det = await api('/api/rule/opp-dedup-rule/detail?id=' + D.d1.id).catch(() => null);
after = det ? ',D1.referenceCount 回落=' + det.referenceCount : '';
}
okBox('dedupOut', '清理推送/查重草稿 ' + n + '/3' + after);
} catch (e) { failBox('dedupOut', e); }
}
/* ================= A7 · 项目规则(票 10) ================= */
async function prCurrent() {
try {
const d = await api('/api/rule/project-rule/current');
const keys = ['stageAliases', 'poolMembers', 'conflictPull', 'stageReminds', 'bidConfigs'];
const missing = keys.filter(k => !(k in d));
if (missing.length) failBox('projruleOut', new Error('current 缺区块: ' + missing.join(',')));
else okBox('projruleOut', '项目规则聚合回显:' + keys.map(k => k + '=' + (Array.isArray(d[k]) ? d[k].length + '行' : (d[k] ? '对象' : '空'))).join(','));
} catch (e) { failBox('projruleOut', e); }
}
async function prSaveAlias() {
try {
await api('/api/rule/project-rule/stage-alias/save', { method: 'POST', body: qs({
'stageAliases[0].stageCode': 'S1', 'stageAliases[0].stageName': '阶段一', 'stageAliases[0].alias': 'e2d别名一-' + TS,
'stageAliases[1].stageCode': 'S2', 'stageAliases[1].stageName': '阶段二', 'stageAliases[1].alias': 'e2d别名二-' + TS }) });
const d = await api('/api/rule/project-rule/current');
const rows = d.stageAliases || [];
const hit = rows.find(r => r.stageCode === 'S1' && (r.alias || '').startsWith('e2d别名一'));
if (hit) okBox('projruleOut', '阶段别名保存并回显:' + rows.length + ' 行,S1.alias=' + esc(hit.alias));
else failBox('projruleOut', new Error('别名回显未命中: ' + JSON.stringify(rows).slice(0, 150)));
} catch (e) { failBox('projruleOut', e); }
}
async function prBadPct() {
try {
await api('/api/rule/project-rule/bid-config/save', { method: 'POST', body: qs({
'bidConfigs[0].bidTypeKey': 'K1', 'bidConfigs[0].name': 'e2d标底', 'bidConfigs[0].bidOwnerPct': '150', 'bidConfigs[0].winOwnerPct': '0' }) });
failBox('projruleOut', new Error('比例越界未被拦截'));
} catch (e) {
if (e.code === 64036) okBox('projruleOut', '64036 比例校验生效:' + esc(e.message));
else failBox('projruleOut', e);
}
}
/* ================= A7 · 线索池(票 10) ================= */
const POOL = { demoId: null, memberUid: null, importName: 'e2d-导入池-' + TS };
async function freeDeptId() {
// 树序第一个未被公海池占用的部门:每部门仅一池(64003)
const used = new Set();
const pg = await api('/api/rule/pool/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
(pg.content || []).forEach(r => used.add(String(r.deptId)));
const tree = await api('/api/system/depts/tree');
let hit = null;
const walk = ns => { for (const n of (ns || [])) { if (!hit && !used.has(String(n.id))) hit = n.id; walk(n.children); } };
walk(tree);
if (!hit) throw new Error('无空闲部门可建池');
return hit;
}
async function poolCreate() {
try {
const deptId = await freeDeptId();
const name = 'e2d-演示池-' + TS;
// 部门成员须与负责人/协作人不同人:同用户会被「负责人>协作人>部门成员」去重优先级吞并(票 10 设计语义)
const up = await api('/api/system/users/page', { method: 'POST', body: qs({ current: 1, size: 20 }) });
POOL.memberUid = (up.content || []).map(x => String(x.id)).find(id => id !== ADMIN_UID && id !== BUDDY_UID);
if (!POOL.memberUid) throw new Error('找不到第三个用户做部门成员');
await api('/api/rule/pool/saveOrUpdate', { method: 'POST', body: qs({
poolName: name, deptId: String(deptId),
ownerUserId: ADMIN_UID, collaboratorUserIds: BUDDY_UID, memberUserIds: POOL.memberUid,
claimRule: 1, recycleDays: 7, expireDays: 30, dailyClaimLimit: 10, holdLimit: 200 }) });
const pg = await api('/api/rule/pool/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
const row = (pg.content || []).find(x => x.poolName === name);
if (!row) throw new Error('演示池创建后查不到');
POOL.demoId = row.id;
okBox('poolOut', '演示池就绪:id=' + esc(POOL.demoId) + ',名称=' + esc(name) + ',部门=' + esc(deptId) + '(三类成员已提交)');
} catch (e) { failBox('poolOut', e); }
}
async function poolDetail() {
try {
if (!POOL.demoId) throw new Error('先执行步骤1');
const d = await api('/api/rule/pool/detail?id=' + POOL.demoId);
const ownerOk = String(d.ownerUserId) === ADMIN_UID;
const collOk = (d.collaboratorUserIds || []).map(String).includes(BUDDY_UID);
const memOk = (d.memberUserIds || []).map(String).includes(String(POOL.memberUid));
if (ownerOk && collOk && memOk) okBox('poolOut', '三类成员回填一致:负责人=' + esc(d.ownerUserName || d.ownerUserId) +
',协作人=' + (d.collaboratorUserIds || []).length + ' 人,部门成员=' + (d.memberUserIds || []).length + ' 人(uid=' + esc(POOL.memberUid) + ')');
else failBox('poolOut', new Error('成员回填不符: owner=' + d.ownerUserId + ' collab=' + JSON.stringify(d.collaboratorUserIds) + ' member=' + JSON.stringify(d.memberUserIds) + ' expectMember=' + POOL.memberUid));
} catch (e) { failBox('poolOut', e); }
}
async function poolExport() {
try {
const r = await fetch(API_BASE + '/api/rule/pool/export?current=1&size=50', { headers: { Authorization: 'Bearer ' + TOKEN() } });
if (!r.ok) throw new Error('导出 HTTP ' + r.status);
const buf = new Uint8Array(await r.arrayBuffer());
const isZip = buf[0] === 0x50 && buf[1] === 0x4b;
if (buf.length > 1000 && isZip) okBox('poolOut', '导出成功:' + buf.length + ' 字节,ZIP 魔数 PK ✓(xlsx)');
else failBox('poolOut', new Error('导出内容异常: ' + buf.length + ' 字节, magic=' + buf[0] + buf[1]));
} catch (e) { failBox('poolOut', e); }
}
async function poolImport() {
try {
const f = $('poolImportFile').files[0];
if (!f) throw new Error('请先在步骤4选择导入文件(脚本预置)');
const fd = new FormData();
fd.append('file', f);
const d = await api('/api/rule/pool/import', { method: 'POST', body: fd });
const succ = d && d.successCount != null ? Number(d.successCount) : -1;
const failCnt = d && d.failCount != null ? Number(d.failCount) : -1;
logLine('poolLog', 'import BatchResult → ' + JSON.stringify(d).slice(0, 200));
if (succ >= 1 && failCnt === 0) okBox('poolOut', '导入成功:BatchResult total=' + esc(d.total) + ',success=' + succ + ',fail=' + failCnt);
else failBox('poolOut', new Error('导入结果异常: ' + JSON.stringify(d).slice(0, 200)));
} catch (e) { failBox('poolOut', e); }
}
async function poolPage() {
try {
const pg = await api('/api/rule/pool/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
const names = (pg.content || []).map(x => x.poolName);
const hasDemo = names.some(n => n === 'e2d-演示池-' + TS);
const hasImport = names.some(n => n === POOL.importName);
if (hasDemo && hasImport) okBox('poolOut', '列表可见:演示池 + 导入池均在(共 e2d- 池 ' + names.length + ' 个)');
else failBox('poolOut', new Error('列表缺池: demo=' + hasDemo + ' import=' + hasImport + ',实际=' + names.join(',')));
} catch (e) { failBox('poolOut', e); }
}
async function poolCleanup() {
try {
let n = 0;
const pg = await api('/api/rule/pool/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
for (const row of (pg.content || [])) {
if (row.poolName === 'e2d-演示池-' + TS || row.poolName === POOL.importName) {
try { await api('/api/rule/pool/delete?id=' + row.id, { method: 'POST' }); n++; }
catch (e) { logLine('poolLog', '删池 ' + row.poolName + ': ' + e.message); }
}
}
okBox('poolOut', '清理演示池/导入池 ' + n + '/2');
} catch (e) { failBox('poolOut', e); }
}
/* ================= A7 · 字典种子验证(票 11) ================= */
async function dictSeed() {
try {
const gp = await api('/api/dict/group/page', { method: 'POST', body: qs({ current: 1, size: 50, keyword: 'opp_stage' }) });
const gs = (gp.content || []).find(x => x.code === 'opp_stage');
if (!gs) throw new Error('opp_stage 分组不存在');
const ip = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 50, groupId: gs.id }) });
const items = ip.content || [];
const has06 = items.some(i => i.code === 'OPP_STAGE_06' && /技术交流/.test(i.name));
const has07 = items.some(i => i.code === 'OPP_STAGE_07' && /竞品打压/.test(i.name));
const gp2 = await api('/api/dict/group/page', { method: 'POST', body: qs({ current: 1, size: 50, keyword: 'opportunity_status' }) });
const gs2 = (gp2.content || []).find(x => x.code === 'opportunity_status');
if (!gs2) throw new Error('opportunity_status 分组不存在');
const ip2 = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 50, groupId: gs2.id }) });
const names2 = (ip2.content || []).map(i => i.name).join('、');
if (has06 && has07 && (ip2.content || []).length === 4)
okBox('seedOut', '字典种子在位:opp_stage ' + items.length + ' 值含 06技术交流/07竞品打压;opportunity_status 4 值(' + esc(names2) + ')');
else failBox('seedOut', new Error('种子不符: 06=' + has06 + ' 07=' + has07 + ' status组=' + (ip2.content || []).length + ' 值'));
} catch (e) { failBox('seedOut', e); }
}
/* ================= 初始化 ================= */
$('apiBox').textContent = 'API: ' + API_BASE;
paintWho();
// 仅已登录才预加载:未登录请求会被 401 拒绝,而安全层响应不带 CORS 头,会刷 console CORS error
if (ME()) { loadUsers(); loadGroups().then(loadGroupOptions); }
</script>
</body>
</html>