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.
 
 
 
 
 

2190 lines
140 KiB

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>A7 后台管理 E2E 验收 Demo(a7-acceptance · 15 页全分区)</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="sync" onclick="switchTab('sync')">同步任务</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="poolrule" onclick="switchTab('poolrule')">公海绑定数</button>
<button data-tab="glog" onclick="switchTab('glog')">全局日志</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><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)。
<b>原型 v3(20260909,dict-alignment 票 11)</b>:默认值配置移到分组列表——行上「设置默认值」只可选该组<b>最低级</b>项(两级组→仅二级项;一级组→一级项),每组唯一、换设同事务自动替换;列表「默认值」列回显(两级组「父名 / 子名」)。
</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>
</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 拦);
<b>原型 v3(20260909,票 11)</b><b>「字典键值」字段删除</b>——列表/弹窗/载荷均无键值;名称组内唯一(63007,编码仍 63006);
<b>默认值移到分组列表「设置默认值」</b>(最低级守卫 63012),保存载荷不再接受 isDefault;子项新建仍固定启用。
<b>最后一个子项</b>请用展开行的「删除」(children 空载荷=单项保存,不触发全量替换)。
其余沿用:编码空自动生成 <code>dict_</code>+UUID、创建后只读(63008);<b>分组默认项禁止停用</b>(63013);
未被引用且无子项可换组(目标组编码/名称双重唯一);删除被引用阻断(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>
<!-- ========== Tab 同步任务(票 01 · A7-1-2) ========== -->
<section id="tab-sync">
<div class="panel">
<div class="viewbar">
<input id="sTaskNo" placeholder="任务编号" style="width:150px">
<select id="sTrigger"><option value="">全部触发方式</option><option value="manual">手动同步</option><option value="auto">自动同步</option></select>
<select id="sStatus"><option value="">全部执行状态</option><option value="running">同步中</option><option value="success">成功</option><option value="partial_failed">部分失败</option><option value="all_failed">全部失败</option></select>
<button class="act" onclick="syncPage()">查询</button>
<span class="sp"></span>
<button class="act primary" onclick="syncLaunch()">发起同步</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="sBody"><tr><td colspan="8" class="empty">登录后自动加载</td></tr></tbody></table>
<div class="hint" style="margin-top:6px">列对齐原型 u88-u135;部门/人员变更为「新增 X / 更新 X」前端拼装口径</div>
</div>
<div class="panel refbox">
<b>同步任务联测(票 01:任务化契约 / 分页倒序 / 详情五块 / 设置读写+边界 / lastSyncTime 切表)</b>
<div class="hint" style="margin:6px 0 8px">
发起即建任务行返任务编号(SYNC-MMdd-序号),执行异步;verify 环境钉钉凭据不可达时任务以失败终态落行(两态容忍);
已有任务运行中发起报 61023 并发占位——两态均验任务化契约。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="syncLaunch()">发起同步任务(任务编号格式 + 异步终态轮询)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="syncPage()">同步记录分页(筛选 + 开始时间倒序断言)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="syncDetail()">任务详情(执行结果/失败对象/接口错误/处理结果/进度五块)</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="syncSettingsRead()">读取同步设置(默认值 + 派生出参)</button></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="syncSettingsSave()">保存合法设置(interval=2 次数=3 并发=1,回显断言)</button></span></div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="syncSettingsBoundary()">边界校验(间隔&lt;1min / 次数&lt;1 → 期望 61022)</button></span></div>
<div class="step"><b>步骤7</b><span><button class="act" onclick="syncSettingsRestore()">还原设置(不污染环境)</button></span></div>
<div class="step"><b>步骤8</b><span><button class="act" onclick="syncStats()">lastSyncTime 数据源切换(任务表最新成功行口径)+ 下次执行时间推导</button></span></div>
</div>
<div id="syncOut"></div>
<div class="panel"><b>执行日志</b><div id="syncLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 全局日志(票 02 · A7-4,crm-log) ========== -->
<section id="tab-glog" style="display:none">
<div class="panel refbox">
<b>全局日志联测(票 02:AOP 写端点采集 / 内联退出埋点 / 批量导出导入留档 / 结果文件下载 / 权限口径)</b>
<div class="hint" style="margin:6px 0 8px">
采集=混合:AOP 兜底全 Controller POST(auth/log/file 排除)+ 内联埋点采登录/退出/批量;
覆盖面=写侧先行不采「查询」;结果文件 90 天保留(过期分支单测覆盖,e2e 验不存在 id 错误形态)。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="logPrepare()">建/删演示角色(AOP 写端点采集素材)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="logOperationPage()">操作日志分页(operatorName/opType 筛选命中 + 逐字段)</button></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="logLogoutRow()">退出登录(内联埋点)→ 登录日志「退出」行断言</button><span class="hint">debug/token 不走登录采集点(票内已核实),退出钩子按请求登录态采集——验证后自动重铸 token</span></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="logExport()">线索池导出 → batch/page「导出」行(result+结果文件 id)</button></span></div>
<div class="step"><b>步骤5</b>
<span>
<input type="file" id="logImportFile">
<button class="act" onclick="logImport()">导入上方文件 → batch/page「导入」行(BatchResult 计数)</button>
<span class="hint">脚本预置 12 列模板 xlsx(1 条 e2d-导入池,部门取空闲顺位避让 64003)</span>
</span>
</div>
<div class="step"><b>步骤6</b><span><button class="act" onclick="logFileDownload()">结果文件下载 /api/log/batch/file?id=(ZIP 魔数)</button></span></div>
<div class="step"><b>步骤7</b><span><button class="act" onclick="logPackDownload()">打包下载 /api/log/batch/download ids[](ZIP 魔数)</button></span></div>
<div class="step"><b>步骤8</b><span><button class="act" onclick="logMissingFile()">不存在文件 id 错误形态(40401/404 形态)</button></span></div>
<div class="step"><b>步骤9</b><span><button class="act" onclick="logDenied()">非管理员(BUDDY)调三 page(期望权限拒绝)</button></span></div>
</div>
<div id="glogOut"></div>
<div class="panel"><b>执行日志</b><div id="glogLog" class="reflog">(等待操作)</div></div>
</section>
<!-- ========== Tab 公海绑定数(票 03 · A7-3-4 挂账毕业) ========== -->
<section id="tab-poolrule" style="display:none">
<div class="panel refbox">
<b>公海规则「绑定商机(数量)」联测(票 03:detail 动态统计零 DDL,match 语义)</b>
<div class="hint" style="margin:6px 0 8px">
口径:部门专用 = deptIds 内商机数;默认 = 不命中任何发布中专用规则的商机数(排除集=发布中专用规则部门并集);
公海态照常计入(owner_dept_id 保留即 match 输入);排除已转项目 opp_status=5;port 缺失 fail-open 按 0。
断言用增量口径(base+n):已关闭商机仍计数(仅 opp_status=5 排除),保证两轮幂等重跑。
</div>
<div class="step"><b>步骤1</b><span><button class="act" onclick="prPrepare()">建演示部门专用规则并发布(e2d-绑定数规则;部门被占则回退既有发布中专用规则)</button></span></div>
<div class="step"><b>步骤2</b><span><button class="act" onclick="prCreateA()">建演示商机 A → detail.boundOppCount = base+1</button><span class="hint">POST /api/opportunity 六必填+省市 code(照 opportunity-e2e 先例)</span></span></div>
<div class="step"><b>步骤3</b><span><button class="act" onclick="prCreateB()">建演示商机 B → detail.boundOppCount = base+2</button></span></div>
<div class="step"><b>步骤4</b><span><button class="act" onclick="prDefaultRule()">默认规则兜底口径(发布中默认规则计数不因本部门建商机而增)</button><span class="hint">无发布中默认规则时降级为提示(单测已覆盖口径)</span></span></div>
<div class="step"><b>步骤5</b><span><button class="act" onclick="prCleanup()">清理(关停演示商机 close / 停用演示规则)</button><span class="hint">商机无删除端点(API 正门终态=关闭);规则发布中→停用留存可辨</span></span></div>
</div>
<div id="poolruleOut"></div>
<div class="panel"><b>执行日志</b><div id="prLog" 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') ? '' : 'https://ai.itc.vip/crm-api';
})();
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(); syncPage();
} 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);
if (t === 'sync' && ME()) syncPage(); // 二期:同步任务页登录后进页即加载
}
/* ================= 弹窗(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>' + (g.defaultItemName ? '<span class="badge b5">' + esc(g.defaultItemName) + '</span>' : '<span class="mark">未设置</span>') + '</td>' +
'<td>' + esc(g.itemCount) + '</td><td style="white-space:normal">' + esc(g.description) + '</td>' +
'<td class="ops">' +
'<button onclick="dlgSetDefault(\'' + g.id + '\')">设置默认值</button>' +
'<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="8" class="empty">暂无分组</td></tr>';
} catch (e) { $('gBody').innerHTML = '<tr><td colspan="8" 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();
}
// ===== 原型 v3(20260909 票 11):分组列表行上设置默认值;只列最低级项(服务端同守卫 63012) =====
async function dlgSetDefault(groupId) {
const g = S_G.rows.find(x => String(x.id) === String(groupId)) || {};
const d = await api('/api/dict/item/page', { method: 'POST', body: qs({ current: 1, size: 200, groupId: groupId }) });
const tops = d.content || [];
const twoLevel = tops.some(t => (t.children || []).length > 0);
const list = twoLevel ? tops.flatMap(t => (t.children || []).map(c => Object.assign({}, c, { _p: t.name }))) : tops;
const rows = list.map(i => '<label style="display:flex;gap:8px;align-items:center;padding:4px 0;cursor:pointer">' +
'<input type="radio" name="sdpick" value="' + esc(i.id) + '"' + (i.isDefault ? ' checked' : '') + '>' +
'<span>' + esc(i.name) + (i.status === 1 ? '' : ' <span class="badge b4">停用</span>') + '</span>' +
'<span class="mark">' + esc(i.code) + '</span>' +
(twoLevel ? '<span class="hint">父项:' + esc(i._p) + '</span>' : '') +
(i.isDefault ? '<span class="badge b5">当前默认</span>' : '') + '</label>').join('');
dlgOpen('设置分组默认值 — ' + (g.name || '') + (twoLevel ? '(两级:只能选二级项)' : '(仅一级:选一级项)'),
'<div class="hint">' + (twoLevel
? '该组存在二级字典项,默认值必须是<b>二级项</b>(最低级);一级项不在可选范围,后端同守卫(63012)。'
: '该组仅一级字典,默认值直接选一级项。') + '</div>' +
'<div>' + (rows || '<span class="hint">该组暂无字典项</span>') + '</div><div id="sdOut"></div>',
[{ text: '取消', fn: dlgClose }, { text: '确定', ok: true, fn: () => saveGroupDefault(groupId) }]);
}
async function saveGroupDefault(groupId) {
const pick = document.querySelector('input[name=sdpick]:checked');
if (!pick) { $('sdOut').innerHTML = '<div class="errbox">请选择一个最低级字典项</div>'; return; }
try { await api('/api/dict/item/set-default?groupId=' + groupId + '&itemId=' + pick.value, { method: 'POST' });
toast('已设为分组默认值(同组原默认自动取消)', 'ok'); dlgClose(); loadGroups();
} catch (e) { $('sdOut').innerHTML = errHtml(e); }
}
/* ================= 字典·字典项管理(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.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 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.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="7" class="hint">(无二级子项)</td></tr>';
}
return html;
}).join('') : '<tr><td colspan="8" class="empty">暂无字典项</td></tr>';
} catch (e) { $('iBody').innerHTML = '<tr><td colspan="8" 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><input class="ksort" value="' + esc(k.sortNo == null ? '' : k.sortNo) + '" style="width:56px"></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(),
sort: tr.querySelector('.ksort').value.trim(),
_del: tr.classList.contains('ghost'),
}));
}
function repaintKids(kids) {
$('kidsBody').innerHTML = kids.map(kidRowHtml).join('') || '<tr><td colspan="4" class="hint">暂无二级子项,点上方按钮添加</td></tr>';
}
function addKidRow() {
const kids = readKidRows();
kids.push({ _tmp: 't' + Date.now() + '_' + kids.length, _new: true, name: '', code: '', 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);
}
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 })) : [];
dlgOpen(id ? (isChild ? '编辑二级字典项(单项保存;分组随父项锁定)' : '编辑字典项(children 全量替换:带 id 更新 / 不带 id 新建 / 缺失删除)')
: '新建字典项(有子项=复合新增一次建;无子项=单项)', `
<label><b>所属分组*</b><select id="fiGroup" ${isChild ? 'disabled' : ''}>${isChild ? `<option value="${esc(it.groupId)}">${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="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><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></tr></thead>
<tbody id="kidsBody">${kids.map(kidRowHtml).join('') || '<tr><td colspan="4" 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 || '';
}
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 body = { groupId: formVal('fiGroup'), name: formVal('fiName'), code: formVal('fiCode'),
sortNo: formVal('fiSort'), status: formVal('fiStatus'), description: formVal('fiDesc') };
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 (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 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); }
}
/* ================= 二期移植(a7-heavy 票 01-03:同步任务 / 全局日志 / 公海绑定数) ================= */
/* 二进制下载助手(对称一期 poolExport):带 token fetch → bytes */
async function fetchBytes(path) {
const r = await fetch(API_BASE + path, { headers: { Authorization: 'Bearer ' + TOKEN() } });
if (!r.ok) throw new Error('HTTP ' + r.status);
return new Uint8Array(await r.arrayBuffer());
}
const isZip = b => b && b.length > 4 && b[0] === 0x50 && b[1] === 0x4b;
/* 步骤输出:box 传裸 id,勿带 #(v2 修订形态) */
function infoBox(box, msg) { outBox(box, '<div class="infobox">' + msg + '</div>'); }
/* 轮询助手:谓词命中即返(采集落库/异步任务终态等待) */
async function waitFor(fn, timeoutMs, intervalMs) {
const deadline = Date.now() + (timeoutMs || 10000);
for (;;) {
let v = null;
try { v = await fn(); } catch (e) {}
if (v) return v;
if (Date.now() > deadline) return null;
await new Promise(r => setTimeout(r, intervalMs || 1000));
}
}
/* ================= 登录(沿底座) ================= */
async function remintToken() {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + ADMIN_UID);
const j = await r.json();
const d = j && j.data;
const tok = typeof d === 'string' ? d : (d && d.token);
if (tok) localStorage.setItem('e2d_token', tok);
return tok;
}
function opName() { const me = ME() || {}; return me.userName || me.nickName || '罗伟健'; }
/* ================= Tab 切换 ================= */
/* ================= 票 01 · 同步任务 ================= */
const SY = { taskId: null, taskNo: null, reused: false, settingsOrig: null };
const TRIGGER_TXT = { manual: '手动同步', auto: '自动同步' };
const SY_STATUS_TXT = { running: '同步中', success: '成功', partial_failed: '部分失败', all_failed: '全部失败' };
function statusBadge(s) {
const t = SY_STATUS_TXT[s] || s || '—';
const cls = s === 'success' ? 'b2' : s === 'running' ? 'b1' : s ? 'b4' : 'b1';
return '<span class="badge ' + cls + '">' + esc(t) + '</span>';
}
async function syncLaunch() {
try {
let d = null;
try {
d = await api('/api/system/sync/dingtalk', { method: 'POST' });
} catch (e) {
if (e.code === 61023) {
SY.reused = true;
okBox('syncOut', '61023 并发占位生效:' + esc(e.message) + '\n(已有任务运行中——复用运行中任务验证任务化结构,契约两态容忍)');
logLine('syncLog', 'launch → 61023 复用运行中任务');
return;
}
throw e;
}
SY.taskId = d && d.taskId; SY.taskNo = d && d.taskNo; SY.reused = false;
logLine('syncLog', 'launch → taskId=' + SY.taskId + ' taskNo=' + SY.taskNo);
const fmtOk = /^SYNC-\d{4}-\d+$/.test(SY.taskNo || '');
if (!fmtOk) { failBox('syncOut', new Error('任务编号格式不符: ' + SY.taskNo)); return; }
// 异步终态轮询(verify 环境钉钉不可达 → 失败终态亦两态容忍;75s 上限防挂死)
const fin = await waitFor(async () => {
const det = await api('/api/system/sync/tasks/detail?id=' + SY.taskId);
return det && det.endTime ? det : null;
}, 75000, 3000);
const st = fin ? fin.status : 'running(75s 未终态,任务行在位即可)';
okBox('syncOut', '任务化契约验证:taskId=' + esc(SY.taskId) + ',taskNo=' + esc(SY.taskNo) + '(SYNC-MMdd-序号 ✓)' +
'\n异步终态:' + esc(st) + (fin ? ',progress=' + esc(fin.progress) + ',errorCount=' + esc(fin.errorCount) : '') +
'\n(verify 环境两态容忍:success 或 all_failed 均有完整任务行)');
} catch (e) { failBox('syncOut', e); }
}
async function syncPage() {
try {
const d = await api('/api/system/sync/tasks/page', { method: 'POST', body: qs({
current: 1, size: 50, taskNo: $('sTaskNo').value.trim(), triggerType: $('sTrigger').value, status: $('sStatus').value }) });
const rows = d.content || [];
$('sBody').innerHTML = rows.length ? rows.map(t => '<tr>' +
'<td>' + esc(t.taskNo) + '</td><td>' + esc(fmt(t.startTime)) + '</td><td>' + esc(TRIGGER_TXT[t.triggerType] || t.triggerType) + '</td>' +
'<td>' + statusBadge(t.status) + '</td>' +
'<td>新增 ' + esc(t.deptInserted == null ? '—' : t.deptInserted) + ' / 更新 ' + esc(t.deptUpdated == null ? '—' : t.deptUpdated) + '</td>' +
'<td>新增 ' + esc(t.userInserted == null ? '—' : t.userInserted) + ' / 更新 ' + esc(t.userUpdated == null ? '—' : t.userUpdated) + '</td>' +
'<td>' + esc(t.errorCount == null ? '—' : t.errorCount) + '</td><td>' + esc(t.progress == null ? '—' : t.progress + '%') + '</td>' +
'</tr>').join('') : '<tr><td colspan="8" class="empty">暂无同步任务</td></tr>';
window._syncRows = rows;
} catch (e) { $('sBody').innerHTML = '<tr><td colspan="8" class="errbox">' + esc(e.message) + codeSuffix(e) + '</td></tr>'; }
}
async function syncDetail() {
try {
let id = SY.taskId;
if (!id) {
const rows = window._syncRows || [];
id = rows.length ? rows[0].id : null;
SY.reused = true;
if (!id) { failBox('syncOut', new Error('无任务行可验详情(先执行步骤1或查询)')); return; }
}
const det = await api('/api/system/sync/tasks/detail?id=' + id);
const need = ['taskNo', 'triggerType', 'status', 'startTime', 'progress'];
const missing = need.filter(k => det[k] === undefined);
const fails = det.failDetails || [];
const failPart = fails.length ? '\n失败对象=' + esc(fails[0].failObject == null ? fails[0].objectName || '—' : fails[0].failObject) +
',接口错误=' + esc((fails[0].apiError || fails[0].errorMsg || '—') + '').slice(0, 80) +
',处理结果=' + esc(fails[0].handleResult == null ? '—' : fails[0].handleResult) : '';
if (missing.length) { failBox('syncOut', new Error('详情缺字段: ' + missing.join(','))); return; }
okBox('syncOut', '详情五块结构在位:taskNo=' + esc(det.taskNo) + ',status=' + esc(SY_STATUS_TXT[det.status] || det.status) +
',进度=' + esc(det.progress) + '%,失败明细 ' + fails.length + ' 条' + failPart);
logLine('syncLog', 'detail → ' + JSON.stringify(det).slice(0, 300));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsRead() {
try {
const d = await api('/api/system/sync/settings');
SY.settingsOrig = d;
okBox('syncOut', '设置回显:autoSyncEnabled=' + esc(d.autoSyncEnabled) + ',dailyTime=' + esc(d.dailyTime) +
',retryIntervalMinutes=' + esc(d.retryIntervalMinutes) + ',retryMaxTimes=' + esc(d.retryMaxTimes) +
',concurrency=' + esc(d.concurrency) +
'\n派生:当前计划=' + esc(d.plan) + ',下次执行时间=' + esc(fmt(d.nextExecuteTime)) +
',最近修改人=' + esc(d.lastModifierName == null ? '—' : d.lastModifierName));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsSave() {
try {
const o = SY.settingsOrig || {};
const d = await api('/api/system/sync/settings', { method: 'POST', body: qs({
autoSyncEnabled: o.autoSyncEnabled == null ? 'true' : String(o.autoSyncEnabled),
dailyTime: o.dailyTime || '02:00',
retryIntervalMinutes: 2, retryMaxTimes: 3, concurrency: 1 }) });
SY.savedValues = { retryIntervalMinutes: 2, retryMaxTimes: 3, concurrency: 1 };
const ok = d && Number(d.retryIntervalMinutes) === 2 && Number(d.retryMaxTimes) === 3 && Number(d.concurrency) === 1;
if (ok) okBox('syncOut', '合法设置保存并回显:interval=2,次数=3,并发=1(autoSyncEnabled/dailyTime 保留原值不污染调度)');
else failBox('syncOut', new Error('保存回显不符: ' + JSON.stringify(d).slice(0, 160)));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsBoundary() {
try {
const o = SY.settingsOrig || {};
const base = { autoSyncEnabled: o.autoSyncEnabled == null ? 'true' : String(o.autoSyncEnabled), dailyTime: o.dailyTime || '02:00' };
let blocked1 = null, blocked2 = null;
try { await api('/api/system/sync/settings', { method: 'POST', body: qs(Object.assign({}, base, { retryIntervalMinutes: 0, retryMaxTimes: 3, concurrency: 1 })) }); }
catch (e) { blocked1 = e; }
try { await api('/api/system/sync/settings', { method: 'POST', body: qs(Object.assign({}, base, { retryIntervalMinutes: 2, retryMaxTimes: 0, concurrency: 1 })) }); }
catch (e) { blocked2 = e; }
if (blocked1 && blocked1.code === 61022 && blocked2 && blocked2.code === 61022)
okBox('syncOut', '61022 边界校验两路生效:间隔<1min 拒(' + esc(blocked1.message) + ');次数<1 拒(' + esc(blocked2.message) + ')');
else failBox('syncOut', new Error('边界校验不符: interval=' + (blocked1 ? blocked1.code : '放行!') + ' times=' + (blocked2 ? blocked2.code : '放行!')));
} catch (e) { failBox('syncOut', e); }
}
async function syncSettingsRestore() {
try {
const o = SY.settingsOrig;
if (!o) { failBox('syncOut', new Error('先执行步骤4读取设置')); return; }
const d = await api('/api/system/sync/settings', { method: 'POST', body: qs({
autoSyncEnabled: String(o.autoSyncEnabled), dailyTime: o.dailyTime || '02:00',
retryIntervalMinutes: o.retryIntervalMinutes == null ? 5 : o.retryIntervalMinutes,
retryMaxTimes: o.retryMaxTimes == null ? 3 : o.retryMaxTimes,
concurrency: o.concurrency == null ? 1 : o.concurrency }) });
const ok = d && Number(d.retryIntervalMinutes) === Number(o.retryIntervalMinutes == null ? 5 : o.retryIntervalMinutes);
if (ok) okBox('syncOut', '设置已还原(interval=' + esc(d.retryIntervalMinutes) + ',次数=' + esc(d.retryMaxTimes) + ',并发=' + esc(d.concurrency) + ',dailyTime=' + esc(d.dailyTime) + ')');
else failBox('syncOut', new Error('还原回显不符: ' + JSON.stringify(d).slice(0, 160)));
} catch (e) { failBox('syncOut', e); }
}
async function syncStats() {
try {
const d = await api('/api/system/users/stats');
if (d.lastSyncTime === undefined) { failBox('syncOut', new Error('stats 出参缺 lastSyncTime 字段')); return; }
// 数据源切换口径:任务表最新成功行 endTime;无成功任务行时允许为空(verify 环境钉钉不可达常态)
const pg = await api('/api/system/sync/tasks/page', { method: 'POST', body: qs({ current: 1, size: 100, status: 'success' }) });
const rows = pg.content || [];
let verdict;
if (rows.length) {
const latest = rows[0]; // 开始时间倒序首行=最新
const eq = fmt(d.lastSyncTime) === fmt(latest.endTime);
verdict = eq ? '与最新成功任务行 endTime 一致(' + esc(fmt(latest.endTime)) + ')——切表生效'
: '⚠ 与最新成功行不一致: stats=' + esc(fmt(d.lastSyncTime)) + ' task=' + esc(fmt(latest.endTime));
if (!eq) { failBox('syncOut', new Error(verdict)); return; }
} else {
verdict = d.lastSyncTime == null ? '无成功任务行 → lastSyncTime 为空(口径自洽)' : '⚠ 无成功任务行但 lastSyncTime=' + esc(fmt(d.lastSyncTime));
if (d.lastSyncTime != null) { failBox('syncOut', new Error(verdict)); return; }
}
const st = await api('/api/system/sync/settings');
const nextOk = st.autoSyncEnabled ? st.nextExecuteTime != null : st.nextExecuteTime == null;
okBox('syncOut', 'lastSyncTime 验证:' + verdict + '\n下次执行时间推导:autoSyncEnabled=' + esc(st.autoSyncEnabled) +
',nextExecuteTime=' + esc(fmt(st.nextExecuteTime)) + (nextOk ? '(推导一致)' : '(⚠ 推导不符)'));
} catch (e) { failBox('syncOut', e); }
}
/* ================= 票 02 · 全局日志 ================= */
const GL = { exportRow: null, importRow: null, exportFileId: null };
async function logPrepare() {
try {
const name = 'e2d-日志演示角色-' + TS;
await api('/api/roles/saveOrUpdate', { method: 'POST', body: qs({ roleName: name, roleCode: 'e2d_lrole_' + TS, remark: 'a7-heavy e2e 写操作素材' }) });
const pg = await api('/api/roles/page', { method: 'POST', body: qs({ current: 1, size: 20, roleName: name }) });
const row = (pg.content || []).find(x => x.roleName === name);
if (!row) throw new Error('演示角色创建后查不到');
await api('/api/roles/delete?id=' + row.id, { method: 'POST' });
logLine('glogLog', '写操作素材:建+删角色 ' + name + '(id=' + row.id + ',AOP 应采「新增」「删除」两行)');
okBox('glogOut', '写操作完成:建/删演示角色 ' + esc(name) + '(等待采集落库后断言)');
} catch (e) { failBox('glogOut', e); }
}
async function findLogRow(path, body, pred, timeoutMs) {
return waitFor(async () => {
const d = await api(path, { method: 'POST', body: qs(body) });
const hit = (d.content || []).find(pred);
return hit ? { row: hit, page: d } : null;
}, timeoutMs || 15000, 1500);
}
async function logOperationPage() {
try {
const op = opName();
const c1 = await findLogRow('/api/log/operation/page', { current: 1, size: 50, operatorName: op, opType: '新增' },
r => (r.content || '').includes('/api/roles/saveOrUpdate'));
const c2 = await findLogRow('/api/log/operation/page', { current: 1, size: 50, operatorName: op, opType: '删除' },
r => (r.content || '').includes('/api/roles/delete'));
if (!c1 || !c2) { failBox('glogOut', new Error('AOP 采集行未命中: 新增=' + !!c1 + ' 删除=' + !!c2)); return; }
// 逐字段:时间范围收口(今日有行 / 昨日 0 行)
const today = new Date(); const p = n => String(n).padStart(2, '0');
const dstr = today.getFullYear() + '-' + p(today.getMonth() + 1) + '-' + p(today.getDate());
const inRange = await api('/api/log/operation/page', { method: 'POST', body: qs({ current: 1, size: 10, operatorName: op, timeFrom: dstr, timeTo: dstr }) });
const outRange = await api('/api/log/operation/page', { method: 'POST', body: qs({ current: 1, size: 10, operatorName: op, timeFrom: '2000-01-01', timeTo: '2000-01-02' }) });
const r = c1.row;
okBox('glogOut', 'AOP 采集行命中:新增行 content=' + esc((r.content || '').slice(0, 60)) +
',操作人=' + esc(r.operatorName) + ',结果=' + esc(r.result) +
'\n逐字段:今日范围 ' + (inRange.content || []).length + ' 行 / 2000-01-01 范围 ' + (outRange.content || []).length + ' 行(时间过滤生效)');
logLine('glogLog', 'operation rows: ' + JSON.stringify(c1.row).slice(0, 200));
} catch (e) { failBox('glogOut', e); }
}
async function logLogoutRow() {
try {
const op = opName();
await api('/api/auth/logout', { method: 'POST' });
await remintToken(); // session 已关闭,立即重铸(采集行已在退出请求内落库)
const c = await findLogRow('/api/log/login/page', { current: 1, size: 20, operatorName: op, opType: '退出' },
r => r.operatorName === op && (r.result || r.status) === '成功'); // 登录日志行字段是 status
if (!c) { failBox('glogOut', new Error('登录日志「退出」行未命中(operatorName=' + op + ')')); return; }
const r = c.row;
okBox('glogOut', '退出埋点命中:opType=退出,operatorName=' + esc(r.operatorName) + ',status/result=' + esc(r.status || r.result) +
',ip=' + esc(r.ip || '—') + ',opTime=' + esc(fmt(r.opTime)) + '\n(token 已重铸,后续步骤不受影响)');
} catch (e) { failBox('glogOut', e); }
}
async function logExport() {
try {
const op = opName();
const buf = await fetchBytes('/api/rule/pool/export?current=1&size=5');
if (!isZip(buf)) { failBox('glogOut', new Error('导出内容非 ZIP: ' + buf.length + ' 字节')); return; }
logLine('glogLog', 'pool export → ' + buf.length + ' 字节 ZIP ✓');
const c = await findLogRow('/api/log/batch/page', { current: 1, size: 20, operatorName: op, opType: '导出' },
r => r.fileId != null && r.fileId !== '');
if (!c) { failBox('glogOut', new Error('批量日志「导出」行(含结果文件)未命中')); return; }
GL.exportRow = c.row; GL.exportFileId = String(c.row.fileId);
okBox('glogOut', '导出留档行命中:page=' + esc(c.row.page || '—') + ',result=' + esc(c.row.result) +
',countDetail=' + esc(c.row.countDetail || '—') + ',fileId=' + esc(GL.exportFileId) + '\n(导出字节 ' + buf.length + ',ZIP 魔数 ✓)');
} catch (e) { failBox('glogOut', e); }
}
async function logImport() {
try {
const op = opName();
const f = $('logImportFile').files[0];
if (!f) throw new Error('请先在步骤5选择导入文件(脚本预置)');
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;
logLine('glogLog', 'pool import → ' + JSON.stringify(d).slice(0, 200));
const c = await findLogRow('/api/log/batch/page', { current: 1, size: 20, operatorName: op, opType: '导入' },
r => (r.result === '成功' || r.result === '部分成功'));
if (!c) { failBox('glogOut', new Error('批量日志「导入」行未命中')); return; }
GL.importRow = c.row;
okBox('glogOut', '导入行命中:result=' + esc(c.row.result) + ',countDetail=' + esc(c.row.countDetail || '—') +
',fileId=' + esc(c.row.fileId == null || c.row.fileId === '' ? '(无失败明细→无错误报告文件,合理)' : c.row.fileId) +
'\nBatchResult: total=' + esc(d.total) + ',success=' + esc(succ) + ',fail=' + esc(d.failCount));
} catch (e) { failBox('glogOut', e); }
}
async function logFileDownload() {
try {
if (!GL.exportRow) throw new Error('先执行步骤4(导出留档行)');
// 端点收的是批量日志行主键(requireBatchLog(id) → row.fileId),非 fileId 本身;导出行才有结果文件
const buf = await fetchBytes('/api/log/batch/file?id=' + GL.exportRow.id);
if (!isZip(buf)) { failBox('glogOut', new Error('结果文件非 ZIP(xlsx):' + buf.length + ' 字节')); return; }
okBox('glogOut', '结果文件下载成功:日志行 id=' + esc(GL.exportRow.id) + '(fileId=' + esc(GL.exportFileId) + '),' + buf.length + ' 字节,ZIP 魔数 PK ✓(90 天内有效)');
} catch (e) { failBox('glogOut', e); }
}
async function logPackDownload() {
try {
const ids = [GL.exportRow, GL.importRow].filter(Boolean).map(r => 'ids=' + r.id);
if (!ids.length) throw new Error('先执行步骤4/5(批量日志行)');
// 端点 @PostMapping(GET 会被安全层 405 且无 CORS 头)——POST 表单多值 ids
const r = await fetch(API_BASE + '/api/log/batch/download', { method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', Authorization: 'Bearer ' + TOKEN() },
body: ids.join('&') });
if (!r.ok) throw new Error('HTTP ' + r.status);
const buf = new Uint8Array(await r.arrayBuffer());
// isZip 之外加字节数下限:空 ZIP(仅 end-of-central-dir)恰为 22 字节且魔数合法,文件全不可取时会误 PASS
if (!isZip(buf) || buf.length < 100) { failBox('glogOut', new Error('打包下载非 ZIP 或空包: ' + buf.length + ' 字节')); return; }
okBox('glogOut', '打包下载成功:ids=[' + ids.map(s => s.replace('ids=', '')).join(', ') + '],' + buf.length + ' 字节,ZIP 魔数 PK ✓');
} catch (e) { failBox('glogOut', e); }
}
async function logMissingFile() {
let code = null, msg = '';
try {
const buf = await fetchBytes('/api/log/batch/file?id=999999999999');
// 全局异常处理器对该端点返 HTTP 200 + JSON 信封(requireBatchLog→ResourceNotExistException 40401)
const head = new TextDecoder().decode(buf.slice(0, 500));
try { const j = JSON.parse(head); code = j.code; msg = j.message || ''; }
catch (e2) { msg = '非 JSON 响应 ' + buf.length + ' 字节'; }
} catch (e) {
code = e.code; msg = e.message || ''; // HTTP 层错误(404 等)形态兜底
}
const codeOk = code === 40401 || code === 66001 || code === 66002 || /HTTP 404/.test(msg);
if (codeOk) okBox('glogOut', '不存在文件 id 错误形态生效:code=' + esc(code) + ',' + esc(msg) + '\n(查无日志行→40401/HTTP404;过期→66001;行存在但无文件→66002;90 天分支单测覆盖)');
else failBox('glogOut', new Error('错误形态不符: code=' + code + ' msg=' + msg));
}
async function logDenied() {
try {
const r = await fetch(API_BASE + '/api/auth/debug/token?userId=' + BUDDY_UID);
const j = await r.json();
const d = j && j.data;
const btok = typeof d === 'string' ? d : (d && d.token);
if (!btok) throw new Error('BUDDY token 获取失败');
const paths = ['/api/log/operation/page', '/api/log/login/page', '/api/log/batch/page'];
const verdicts = [];
for (const p of paths) {
let denied = false, shape = '';
try {
const resp = await fetch(API_BASE + p, { method: 'POST', headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', 'Authorization': 'Bearer ' + btok }, body: 'current=1&size=5' });
shape = 'HTTP ' + resp.status;
denied = resp.status === 403;
if (!denied) { const jj = await resp.json().catch(() => null); if (jj && jj.code !== 0) { denied = true; shape += ' code=' + jj.code; } }
} catch (e2) { shape = e2.message; }
verdicts.push(p.split('/').pop() + '=' + (denied ? '拒绝 ✓(' + shape + ')' : '⚠ 未拒绝(' + shape + ')'));
}
const allDenied = verdicts.every(v => v.includes('✓'));
if (allDenied) okBox('glogOut', '非管理员权限口径生效(ADMIN 门禁):\n' + verdicts.join('\n'));
else failBox('glogOut', new Error('权限拒绝不符:\n' + verdicts.join('\n')));
} catch (e) { failBox('glogOut', e); }
}
/* ================= 票 03 · 公海绑定数 ================= */
const PR = { ruleId: null, created: false, base: null, opps: [], defaultRow: null, defaultBase: null };
const OPP_BASE = { oppSource: 'opp_source_03', industryCode: 'gov', bidForm: 'bid_form_02', localityType: 'locality_type_02',
provinceCode: '510000', cityCode: '510100', partyAClear: 0, partyA: 'E2E甲方-a7heavy', remark: 'a7-heavy 票05 e2e' };
async function findPoolRuleByName(name) {
const d = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 100, ruleName: 'e2d-绑定数规则' }) });
return (d.content || []).filter(x => (x.ruleName || '').startsWith('e2d-绑定数规则'));
}
async function prPrepare() {
try {
const me = ME() || {};
if (!me.deptId) throw new Error('me 出参缺 deptId(无法定位规则绑定部门)');
const deptId = String(me.deptId);
// 幂等:清掉前轮演示规则(草稿删 / 发布中停用——停用行留痕可辨)
for (const old of await findPoolRuleByName()) {
try {
if (Number(old.status) === 1) { await api('/api/rule/opp-pool-rule/delete?id=' + old.id, { method: 'POST' }); logLine('prLog', 'pre-clean 删草稿 ' + old.ruleName); }
else if (Number(old.status) === 2) { await api('/api/rule/opp-pool-rule/disable?id=' + old.id, { method: 'POST' }); logLine('prLog', 'pre-clean 停用前轮发布行 ' + old.ruleName); }
} catch (e) { logLine('prLog', 'pre-clean 跳过 ' + old.ruleName + ': ' + e.message); }
}
// 发布演示部门专用规则(部门被既有发布中专用规则占用时回退用该规则断言)
let created = true;
try {
await api('/api/rule/opp-pool-rule/publish', { method: 'POST', body: qs({
ruleName: 'e2d-绑定数规则-' + TS, applyScope: 2, deptIds: deptId, isDefault: 0,
allowManualPool: 1, autoRecycleEnabled: 0, recycleRemindEnabled: 0, allowFreeClaim: 1,
ruleDesc: 'a7-heavy 票03 e2e' }) }); // 四旗标全必传(缺 recycleRemindEnabled → 64008)
} catch (e) {
created = false;
logLine('prLog', '发布被拒(' + e.message + ')→ 回退既有发布中专用规则');
}
let rule = null;
if (created) {
const pg = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 100, ruleName: 'e2d-绑定数规则-' + TS }) });
rule = (pg.content || []).find(x => x.ruleName === 'e2d-绑定数规则-' + TS && Number(x.status) === 2);
}
if (!rule) {
const pg = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
for (const r of (pg.content || []).filter(x => Number(x.status) === 2 && Number(x.applyScope) === 2)) {
const det = await api('/api/rule/opp-pool-rule/detail?id=' + r.id);
if ((det.deptIds || []).map(String).includes(deptId)) { rule = det; break; }
}
}
if (!rule) throw new Error('无可用发布中部门专用规则(含本部门)');
PR.ruleId = rule.id; PR.created = created;
const det = await api('/api/rule/opp-pool-rule/detail?id=' + rule.id);
PR.base = Number(det.boundOppCount == null ? 0 : det.boundOppCount);
PR.opps = [];
okBox('poolruleOut', '规则就绪:id=' + esc(rule.id) + ',name=' + esc(rule.ruleName) +
',scope=' + esc(rule.applyScope) + '(部门专用),deptIds=[' + esc((det.deptIds || []).join(',')) + ']' +
'\nboundOppCount 基线 base=' + esc(PR.base) + (created ? '(本轮新建发布)' : '(部门已被占用→复用既有发布中专用规则)') +
'\n后续按增量断言 base+n(已关闭商机仍计数,两轮幂等安全)');
} catch (e) { failBox('poolruleOut', e); }
}
async function prCreateOpp(label) {
const d = await api('/api/opportunity', { method: 'POST', body: qs(Object.assign({}, OPP_BASE, {
opportunityName: 'e2d-绑定数opp-' + TS + '-' + label })) });
const oppId = String(typeof d === 'object' && d !== null ? (d.id || d) : d);
PR.opps.push(oppId);
const det = await api('/api/rule/opp-pool-rule/detail?id=' + PR.ruleId);
return { oppId: oppId, count: Number(det.boundOppCount == null ? 0 : det.boundOppCount) };
}
async function prCreateA() {
try {
if (!PR.ruleId) throw new Error('先执行步骤1');
const r = await prCreateOpp('A');
const expect = PR.base + 1;
if (r.count === expect) okBox('poolruleOut', '商机 A(id=' + esc(r.oppId) + ')→ boundOppCount=' + r.count + '(base+1 ✓,match 语义生效)');
else failBox('poolruleOut', new Error('计数不符: 实际=' + r.count + ' 期望=' + expect + '(base=' + PR.base + ')'));
} catch (e) { failBox('poolruleOut', e); }
}
async function prCreateB() {
try {
if (!PR.ruleId) throw new Error('先执行步骤1');
const r = await prCreateOpp('B');
const expect = PR.base + 2;
if (r.count === expect) okBox('poolruleOut', '商机 B(id=' + esc(r.oppId) + ')→ boundOppCount=' + r.count + '(base+2 ✓,动态统计非快照)');
else failBox('poolruleOut', new Error('计数不符: 实际=' + r.count + ' 期望=' + expect + '(base=' + PR.base + ')'));
} catch (e) { failBox('poolruleOut', e); }
}
async function prDefaultRule() {
try {
const pg = await api('/api/rule/opp-pool-rule/page', { method: 'POST', body: qs({ current: 1, size: 200 }) });
const def = (pg.content || []).find(x => Number(x.status) === 2 && Number(x.applyScope) === 1 && Number(x.isDefault) === 1);
if (!def) {
infoBox('poolruleOut', '当前无发布中默认规则 → 兜底口径断言降级为提示(口径已由 crm-rule 单测覆盖:默认计数=不命中发布中专用规则的商机数,排除集=发布中专用规则部门并集)');
return;
}
const d0 = await api('/api/rule/opp-pool-rule/detail?id=' + def.id);
const baseD = Number(d0.boundOppCount == null ? 0 : d0.boundOppCount);
const r = await prCreateOpp('C');
const d1 = await api('/api/rule/opp-pool-rule/detail?id=' + def.id);
const nowD = Number(d1.boundOppCount == null ? 0 : d1.boundOppCount);
if (nowD === baseD) okBox('poolruleOut', '默认规则兜底口径生效:发布中默认规则(id=' + esc(def.id) + ')boundOppCount=' + nowD +
' 建商机后不变(本部门被专用规则覆盖 → 进排除集,不入默认计数);专用规则计数=' + r.count);
else failBox('poolruleOut', new Error('默认计数异常变化: ' + baseD + ' → ' + nowD + '(本部门商机不应入默认计数)'));
} catch (e) { failBox('poolruleOut', e); }
}
async function prCleanup() {
try {
let closed = 0;
for (const id of PR.opps) {
try { await api('/api/opportunity/close?id=' + id + '&closeReason=e2e清理&closeRemark=a7-heavy 票05 清理', { method: 'POST' }); closed++; }
catch (e) { logLine('prLog', '关停商机 ' + id + ': ' + e.message + codeSuffix(e)); }
}
let ruleMsg = '无本轮规则';
if (PR.ruleId && PR.created) {
try { await api('/api/rule/opp-pool-rule/disable?id=' + PR.ruleId, { method: 'POST' }); ruleMsg = '演示规则已停用(发布行留存可辨)'; }
catch (e) { ruleMsg = '停用失败: ' + e.message; }
} else if (PR.ruleId) ruleMsg = '复用的既有规则不动';
okBox('poolruleOut', '清理完成:关停演示商机 ' + closed + '/' + PR.opps.length + '(close 终态;已关闭仍计数,仅 opp_status=5 排除——增量口径跨轮安全);' + ruleMsg);
} catch (e) { failBox('poolruleOut', e); }
}
/* ================= 初始化 ================= */
$('apiBox').textContent = 'API: ' + API_BASE;
paintWho();
// 仅已登录才预加载:未登录请求会被 401 拒绝,而安全层响应不带 CORS 头,会刷 console CORS error
if (ME()) { loadUsers(); loadGroups().then(loadGroupOptions); syncPage(); }
</script>
</body>
</html>