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