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.
2263 lines
136 KiB
2263 lines
136 KiB
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<title>客户模块 E2E Demo</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: 16px; padding: 10px 20px; background: #1f2d3d; color: #fff; }
|
|
header h1 { font-size: 17px; font-weight: 600; }
|
|
header select, header button { padding: 4px 10px; border-radius: 4px; border: 1px solid #45566b; background: #2c3e50; color: #fff; }
|
|
header button.primary { background: #409eff; border-color: #409eff; cursor: pointer; }
|
|
header .who { color: #9fb3c8; }
|
|
nav.tabs { display: flex; gap: 4px; padding: 0 20px; background: #fff; border-bottom: 1px solid #e4e7ed; }
|
|
nav.tabs button { padding: 10px 22px; border: none; background: none; font-size: 14px; cursor: pointer; color: #666; border-bottom: 2px solid transparent; }
|
|
nav.tabs button.on { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
|
|
main { padding: 16px 20px; }
|
|
.panel { background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; padding: 14px; margin-bottom: 14px; }
|
|
.viewbar { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 10px; }
|
|
.viewbar button { padding: 5px 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
|
|
.viewbar button.on { background: #409eff; color: #fff; border-color: #409eff; }
|
|
.viewbar .sp { flex: 1; }
|
|
input, select, textarea { padding: 5px 8px; border: 1px solid #dcdfe6; border-radius: 4px; font: inherit; }
|
|
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; }
|
|
tr.row { cursor: pointer; } tr.row:hover td { background: #f0f7ff; }
|
|
.badge { display: inline-block; padding: 1px 8px; border-radius: 10px; font-size: 12px; }
|
|
.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; }
|
|
.toolbar { display: flex; gap: 6px; flex-wrap: wrap; margin: 10px 0; }
|
|
.toolbar button { padding: 5px 12px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; }
|
|
.toolbar button:disabled { color: #bbb; cursor: not-allowed; }
|
|
.subtabs { display: flex; gap: 2px; border-bottom: 1px solid #e4e7ed; margin-bottom: 10px; flex-wrap: wrap; }
|
|
.subtabs button { padding: 7px 14px; border: none; background: none; cursor: pointer; border-bottom: 2px solid transparent; }
|
|
.subtabs button.on { color: #409eff; border-bottom-color: #409eff; font-weight: 600; }
|
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 6px 18px; }
|
|
.grid .kv { display: flex; gap: 8px; } .grid .kv b { color: #909399; font-weight: 400; min-width: 84px; }
|
|
.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: 480px; }
|
|
.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: 460px; max-width: 680px; }
|
|
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; }
|
|
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; }
|
|
.hint { color: #999; font-size: 12px; }
|
|
.loading { color: #409eff; }
|
|
.mark { display: inline-block; margin-left: 4px; padding: 0 6px; border-radius: 8px; background: #ececec; color: #8a8a8a; font-size: 11px; vertical-align: middle; cursor: help; }
|
|
.simbox { background: #fffbe6; border: 1px solid #f5dab1; border-radius: 4px; padding: 8px 12px; font-size: 12px; }
|
|
.sumcards { display: flex; gap: 12px; flex-wrap: wrap; }
|
|
.sumcards .card { border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px 16px; min-width: 130px; }
|
|
.sumcards .card b { display: block; font-size: 20px; color: #409eff; }
|
|
.logday { font-weight: 600; color: #909399; margin: 10px 0 4px; font-size: 13px; }
|
|
/* ===== 联系人图谱/页签增强(A4-4-2 §6,票 02 收编自 customer-contact-graph demo 修订⑧版) ===== */
|
|
.graph-head { display:flex; align-items:center; gap:10px; margin-bottom:8px; flex-wrap:wrap; }
|
|
.graph-head .gtitle { font-size:15px; font-weight:600; }
|
|
.unsaved-chip { display:none; background:#fef3c7; color:#b45309; border:1px solid #fcd34d; font-size:12px; padding:2px 10px; border-radius:10px; }
|
|
.unsaved-chip.show { display:inline-block; }
|
|
#graphWrap { position:relative; height:640px; border:1px solid #e4e7ed; border-radius:8px; overflow:hidden; background:#fff;
|
|
background-image:radial-gradient(#e5e7eb 1px, transparent 1px); background-size:24px 24px; cursor:grab; }
|
|
#graphWrap.panning { cursor:grabbing; }
|
|
#graphWrap.editing { cursor:default; }
|
|
#hintBar { display:none; position:absolute; top:0; left:0; right:0; z-index:30; background:#fff7ed; color:#9a3412; font-size:12px; padding:6px 16px; border-bottom:1px solid #fed7aa; }
|
|
#hintBar.show { display:block; }
|
|
#canvasInner { position:absolute; top:0; left:0; width:2400px; height:1400px; transform-origin:0 0; }
|
|
#edgeSvg { position:absolute; top:0; left:0; width:2400px; height:1400px; pointer-events:none; }
|
|
#edgeSvg .edge-hit { stroke:transparent; stroke-width:16; fill:none; pointer-events:stroke; cursor:pointer; }
|
|
#edgeSvg .edge-line { stroke:#3b82f6; stroke-width:2; }
|
|
#edgeSvg .edge-pending { stroke:#93c5fd; stroke-width:3; }
|
|
#edgeSvg .edge-cycle { stroke:#dc2626 !important; stroke-width:3; }
|
|
#edgeSvg g.edge-g:hover .edge-line { stroke:#dc2626; stroke-dasharray:6 4; }
|
|
#edgeSvg g.edge-company .edge-line { stroke:#93c5fd; stroke-dasharray:7 5; } /* 修订⑧:公司边派生视图,虚线浅蓝不可删 */
|
|
#edgeSvg g.edge-company:hover .edge-line { stroke:#93c5fd; stroke-dasharray:7 5; }
|
|
#edgeSvg .edge-del { opacity:0; }
|
|
#edgeSvg g.edge-g:hover .edge-del { opacity:1; }
|
|
.zoom-ctl { position:absolute; right:14px; bottom:14px; z-index:30; display:flex; align-items:center; background:#fff; border:1px solid #e4e7ed; border-radius:6px; overflow:hidden; }
|
|
.zoom-ctl button { width:34px; height:30px; background:#fff; font-size:16px; color:#374151; border:none; cursor:pointer; }
|
|
.zoom-ctl button:hover { background:#f3f4f6; }
|
|
.zoom-ctl span { font-size:12px; padding:0 8px; min-width:44px; text-align:center; color:#6b7280; }
|
|
.gcard { position:absolute; width:150px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; box-shadow:0 2px 6px rgba(0,0,0,.08); padding:10px 12px 12px; z-index:10; user-select:none; }
|
|
.gcard .avatar { width:34px; height:34px; border-radius:50%; background:#dbeafe; color:#2563eb; font-size:15px; font-weight:600; display:flex; align-items:center; justify-content:center; margin-bottom:6px; }
|
|
.gcard .gname { font-size:14px; font-weight:600; }
|
|
.gcard .gjob { font-size:12px; color:#6b7280; margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:124px; }
|
|
.gcard .gphone { font-size:12px; color:#2563eb; margin-top:4px; text-decoration:underline dotted; cursor:pointer; }
|
|
.gcard .gphone.revealed { color:#1f2937; text-decoration:none; }
|
|
.gcard .top-badge { position:absolute; top:-9px; left:10px; background:#eff6ff; border:1px solid #93c5fd; color:#2563eb; font-size:11px; padding:0 8px; border-radius:8px; }
|
|
.gcard .lv-chip { position:absolute; top:8px; right:8px; background:#eff6ff; border:1px solid #dbeafe; color:#2563eb; font-size:10.5px; padding:0 5px; border-radius:8px; }
|
|
.gcard .dot { display:none; position:absolute; width:12px; height:12px; border-radius:50%; background:#2563eb; border:2px solid #fff; box-shadow:0 0 0 1px #93c5fd; z-index:20; }
|
|
.gcard .dot::after { content:''; position:absolute; left:-9px; top:-9px; right:-9px; bottom:-9px; }
|
|
.gcard .dot-top { top:-7px; left:50%; margin-left:-7px; cursor:crosshair; }
|
|
.gcard .dot-bottom { bottom:-7px; left:50%; margin-left:-7px; cursor:crosshair; }
|
|
body.graph-edit .gcard .dot { display:block; }
|
|
body.graph-edit .gcard { cursor:move; }
|
|
.gcard.company { width:240px; background:#1e3a8a; border-color:#1e3a8a; color:#fff; text-align:center; cursor:default; }
|
|
.gcard.company .gname { font-size:15px; letter-spacing:1px; }
|
|
.gcard.company .gjob { color:#bfdbfe; }
|
|
.gcard.link-target { box-shadow:0 0 0 3px #93c5fd; }
|
|
#unrecZone { position:absolute; border:2px dashed #d1d5db; border-radius:10px; background:rgba(249,250,251,.6); z-index:5; }
|
|
#unrecZone .zone-label { position:absolute; top:8px; left:14px; color:#9ca3af; font-size:12px; }
|
|
#unrecZone .zone-sub { position:absolute; top:26px; left:14px; color:#c1c9d4; font-size:11px; }
|
|
.lv-row { position:absolute; border-radius:8px; background:rgba(241,245,249,.5); border:1px solid #e8edf3; z-index:1; }
|
|
.lv-row .row-label { position:absolute; left:10px; top:6px; font-size:11px; color:#9aa6b5; letter-spacing:1px; }
|
|
.lv-row.drop-target { border-color:#2563eb; background:rgba(219,234,254,.55); }
|
|
#bubble { display:none; position:absolute; z-index:40; width:230px; background:#fff; border:1px solid #e5e7eb; border-radius:8px; box-shadow:0 4px 14px rgba(0,0,0,.14); padding:10px 12px; font-size:12px; line-height:1.9; }
|
|
#bubble .b-title { font-weight:600; border-bottom:1px solid #f3f4f6; padding-bottom:4px; margin-bottom:4px; }
|
|
#bubble .b-row { display:flex; justify-content:space-between; color:#4b5563; }
|
|
#bubble .b-row b { color:#1f2937; font-weight:500; }
|
|
.phone-masked { color:#2563eb; text-decoration:underline dotted; cursor:pointer; }
|
|
.phone-plain { color:#1f2937; }
|
|
.opt-link { color:#2563eb; cursor:pointer; margin-right:10px; font-size:13px; }
|
|
.opt-link.danger { color:#dc2626; }
|
|
.undo-link { color:#ea580c; cursor:pointer; font-size:12px; }
|
|
.dirty-row td { background:#fffbeb !important; }
|
|
tr.err-row td { background:#fef2f2 !important; }
|
|
.sel-info { font-size:12px; color:#2563eb; }
|
|
.edge-chip { display:inline-block; background:#f3f4f6; border-radius:4px; padding:0 6px; margin:2px 4px 2px 0; font-size:11.5px; }
|
|
.edge-chip.manual { background:#dcfce7; color:#166534; }
|
|
.edge-chip.pending { background:#fef9c3; color:#854d0e; }
|
|
.edge-chip.veto { background:#fee2e2; color:#991b1b; text-decoration:line-through; }
|
|
.edge-chip.company { background:#dbeafe; color:#1e40af; }
|
|
.overlay { display:none; position:fixed; inset:0; background:rgba(17,24,39,.45); z-index:100; align-items:flex-start; justify-content:center; padding-top:9vh; }
|
|
.overlay.show { display:flex; }
|
|
.modal { background:#fff; border-radius:10px; width:960px; max-width:94vw; max-height:82vh; display:flex; flex-direction:column; box-shadow:0 12px 40px rgba(0,0,0,.2); }
|
|
.modal-head { display:flex; align-items:center; padding:14px 20px; border-bottom:1px solid #f3f4f6; }
|
|
.modal-head h3 { font-size:15px; }
|
|
.modal-close { margin-left:auto; color:#9ca3af; font-size:18px; background:none; border:none; cursor:pointer; }
|
|
.modal-body { padding:16px 20px; overflow-y:auto; }
|
|
.modal-foot { display:flex; gap:10px; padding:12px 20px; border-top:1px solid #f3f4f6; justify-content:flex-end; }
|
|
.modal-foot .btn, .modal-body .btn { padding:6px 14px; font-size:13px; background:#fff; border:1px solid #dcdfe6; color:#374151; border-radius:4px; cursor:pointer; }
|
|
.modal-foot .btn-primary, .modal-body .btn-primary { background:#409eff; color:#fff; border-color:#409eff; }
|
|
#qaTable { width:100%; border-collapse:collapse; }
|
|
#qaTable th, #qaTable td { border:1px solid #f3f4f6; padding:6px 8px; font-size:12.5px; }
|
|
#qaTable th { background:#f9fafb; color:#6b7280; font-weight:500; }
|
|
#qaTable tr.qa-err td { background:#fef2f2; }
|
|
#qaTable tr.qa-dup td { background:#fffbeb; }
|
|
.qa-hint { font-size:11.5px; color:#92400e; display:block; }
|
|
.star-link { cursor:pointer; font-size:15px; color:#d9a406; user-select:none; }
|
|
.star-link.off { color:#c0c4cc; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>客户模块 E2E Demo</h1>
|
|
<span class="hint" id="apiBox"></span>
|
|
<span>当前用户:</span>
|
|
<select id="userSel" onchange="switchUser(this.value)"></select>
|
|
<input id="uidInput" placeholder="测试环境 userId 直登" style="width:250px;border-radius:4px;border:1px solid #45566b;background:#2c3e50;color:#fff;padding:4px 8px">
|
|
<button onclick="loginByUid()">uid 登录</button>
|
|
<span class="who" id="whoBox">未登录</span>
|
|
<span class="sp" style="flex:1"></span>
|
|
<button class="primary" onclick="dlgCreate()">+ 新建客户</button>
|
|
</header>
|
|
<nav class="tabs">
|
|
<button data-tab="list" class="on" onclick="switchTab('list')">客户列表</button>
|
|
<button data-tab="detail" onclick="openDetailTab()">客户详情</button>
|
|
<button data-tab="import" onclick="switchTab('import');loadImportPage()">客户导入</button>
|
|
<button data-tab="transfer" onclick="switchTab('transfer');transferPage(0)">客户交割</button>
|
|
<button data-tab="settings" onclick="switchTab('settings');loadReminder();loadDedup()">设置</button>
|
|
</nav>
|
|
<main>
|
|
<!-- ========== Tab 1 客户列表 ========== -->
|
|
<section id="tab-list">
|
|
<div class="panel">
|
|
<div class="viewbar">
|
|
<button data-ws="mine" class="on" onclick="switchWs('mine')">我的客户</button>
|
|
<button data-ws="overview" onclick="switchWs('overview')">客户总览</button>
|
|
<button data-ws="pool" onclick="switchWs('pool')">客户公海</button>
|
|
<span class="sp"></span>
|
|
<select id="fViewType" onchange="S.page=1;loadList()">
|
|
<option value="ASSIGNED">我负责的</option>
|
|
<option value="COLLABORATING">我协同的</option>
|
|
<option value="FOCUSED">我关注的</option>
|
|
</select>
|
|
<input id="fKeyword" placeholder="客户名称/编号" style="width:170px">
|
|
<button onclick="S.page=1;loadList()">查询</button>
|
|
<span class="hint" id="listHint"></span>
|
|
</div>
|
|
<table><thead><tr>
|
|
<th>关注</th><th>客户名称</th><th>编号</th><th>阶段</th><th>星级</th><th>负责人</th><th>所属部门</th>
|
|
<th>商机数</th><th>项目数</th><th>最近跟进</th><th>操作</th>
|
|
</tr></thead><tbody id="listBody"></tbody></table>
|
|
<div class="pager">
|
|
<button onclick="loadList(-1)">上一页</button><span id="pageInfo">-</span><button onclick="loadList(1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel"><b>汇总卡(当前 workspace + 视图)</b><div id="summaryBox" class="sumcards hint">随列表加载</div></div>
|
|
<div class="panel">
|
|
<b>选中客户批量操作</b> <span class="hint">列表行点击勾选;操作作用于勾选行或勾选行第一条</span>
|
|
<div class="toolbar">
|
|
<button onclick="dlgAssign()">分配给销售</button>
|
|
<button onclick="batchClaim()">批量领取(公海)</button>
|
|
<button onclick="batchRelease()">批量抛公海</button>
|
|
<button onclick="batchArchive()">批量归档</button>
|
|
<button onclick="batchFocus()">批量关注</button>
|
|
<button onclick="batchCompanyUpdate()">批量工商更新 <span class="mark" title="F6:逐客户按名称查工商源只补空缺字段,单次上限 100,行级五种状态软处置;一期 stub 恒 NO_HIT">F6</span></button>
|
|
<button onclick="dlgTransferEntry()">发起交割 <span class="mark" title="A4-7:离职/调动整批移交,接收总监名下客户整批原子划转">A4-7</span></button>
|
|
</div>
|
|
<div class="hint" id="pickHint">未勾选</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========== Tab 2 客户详情 ========== -->
|
|
<section id="tab-detail" style="display:none">
|
|
<div class="panel"><div id="headBox" class="hint">从列表点击客户行进入;或输入客户 ID:<input id="didInput" style="width:220px"><button onclick="openDetailById()">打开</button></div></div>
|
|
<div class="panel" id="detailPanel" style="display:none">
|
|
<div class="subtabs">
|
|
<button data-s="info" class="on" onclick="switchSub('info')">客户信息</button>
|
|
<button data-s="contacts" onclick="switchSub('contacts')">联系人</button>
|
|
<button data-s="follow" onclick="switchSub('follow')">跟进记录 <span class="mark" title="D-02(P2):写跟进允许 nextFollowTime 早于现在(原型要求拦截)">D-02</span></button>
|
|
<button data-s="opps" onclick="switchSub('opps')">关联商机</button>
|
|
<button data-s="agreements" onclick="switchSub('agreements')">战略协议 <span class="mark" title="页签 6(返工票 12):列表/新增/编辑/删除/详情五端点,写后同步客户协议等级快照">A4-4-6</span></button>
|
|
<button data-s="members" onclick="switchSub('members')">团队成员</button>
|
|
<button data-s="oplog" onclick="switchSub('oplog')">操作日志</button>
|
|
</div>
|
|
<div id="subBox"></div>
|
|
<!-- ===== 联系人页签(票02 收编自 prototype-contact-graph.html,A4-4-2 §6;列表=旧端点 contacts 全字段,图谱=graph/detail) ===== -->
|
|
<div id="contactsShell" style="display:none">
|
|
<div class="viewbar">
|
|
<input id="qKeyword" placeholder="姓名/电话" style="width:150px">
|
|
<select id="qInternal" style="width:110px"><option value="">全部类型</option><option value="0">外部</option><option value="1">内部</option></select>
|
|
<button onclick="queryContacts()">查询</button>
|
|
<button onclick="resetContactsContact()">重置</button>
|
|
<span class="sp"></span>
|
|
<button id="btnEditList" onclick="editListToggle()">批量编辑</button>
|
|
<button id="btnSaveList" onclick="saveListAll()" style="display:none">全部保存</button>
|
|
<button id="btnResetList" onclick="btnResetListClick()" style="display:none">放弃修改</button>
|
|
<button id="btnRevealAll" onclick="toggleRevealAll()">明文查看</button>
|
|
<button onclick="exportContacts()">导出</button>
|
|
<button onclick="openCimp()">导入</button>
|
|
<button id="btnAddContact" onclick="openQa()">快速添加</button>
|
|
<span class="hint" id="selInfo"></span>
|
|
</div>
|
|
<div class="viewbar" style="margin-bottom:6px">
|
|
<button id="pillList" class="on" onclick="switchContactView('list')">列表视图</button>
|
|
<button id="pillGraph" onclick="switchContactView('graph')">图谱视图</button>
|
|
<span class="hint" id="contactCount"></span>
|
|
</div>
|
|
<div id="cListView">
|
|
<table><thead><tr>
|
|
<th style="width:34px"><input type="checkbox" id="chkAll" onchange="chkAllChange(this)"></th>
|
|
<th style="width:34px">#</th><th>姓名</th><th>职务</th><th>联系电话</th><th>关键</th><th>内线</th><th>来源</th><th>礼品备注</th><th>操作</th>
|
|
</tr></thead><tbody id="contactTbody"></tbody></table>
|
|
</div>
|
|
<div id="gView" style="display:none">
|
|
<div class="viewbar" style="margin-bottom:6px">
|
|
<span class="hint">编辑态:拖卡片到等级格子=定级/改级;拖卡片上下圆点=连线;悬停连线点 x =删边。等级1-10(10=顶层),公司边为派生视图不可删。</span>
|
|
<span class="unsaved-chip" id="unsavedChip">有未保存修改</span>
|
|
<span class="sp"></span>
|
|
<button id="btnGraphEdit" onclick="graphEditToggle()">编辑图谱</button>
|
|
<button id="btnGraphSave" onclick="saveGraph()" style="display:none">保存</button>
|
|
<button id="btnGraphReset" onclick="resetGraphEdit()" style="display:none">重置</button>
|
|
</div>
|
|
<div id="graphWrap">
|
|
<div id="hintBar">编辑模式:拖动卡片到等级格子 = 定级/改级;拖动卡片圆点 = 连线;悬停连线点击 x = 删边。完成编辑后请点击「保存」。</div>
|
|
<div id="canvasInner">
|
|
<svg id="edgeSvg"></svg>
|
|
<div id="unrecZone"><span class="zone-label">未设置等级</span><span class="zone-sub">拖到等级格子可定级</span></div>
|
|
</div>
|
|
<div class="zoom-ctl">
|
|
<button onclick="zoomAt(0.87)">-</button><span id="zoomLabel">62%</span><button onclick="zoomAt(1.15)">+</button>
|
|
<button onclick="fitView()">适屏</button>
|
|
</div>
|
|
<div id="bubble"></div>
|
|
</div>
|
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px">
|
|
<div><b class="hint">图谱状态</b><div id="graphStateBox" class="hint" style="max-height:150px;overflow-y:auto"></div></div>
|
|
<div><b class="hint">操作日志(本会话)</b><div id="graphLogBox" class="hint" style="max-height:150px;overflow-y:auto"></div></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========== Tab 3 客户导入 ========== -->
|
|
<section id="tab-import" style="display:none">
|
|
<div class="panel">
|
|
<b>两段式导入<span class="mark" title="D-05(P1):customer.is_biz_negotiated NOT NULL 无默认值且 createCustomer 未赋值——含 INSERT 行的导入必 FAILED(行不落库)。D-04(P2):行异常文本超长致失败明细写不进 fail 表(0 行)">D-05 / D-04</span></b>
|
|
<div class="toolbar">
|
|
<a id="tplLink" href="/api/customer/import/template" target="_blank"><button>下载模板(V1 双 sheet)</button></a>
|
|
<select id="impMode">
|
|
<option value="APPEND_ONLY">APPEND_ONLY 仅新增</option>
|
|
<option value="UPDATE_ONLY">UPDATE_ONLY 仅更新</option>
|
|
<option value="UPSERT">UPSERT 有则更新</option>
|
|
</select>
|
|
<select id="impStrategy" title="F8 duplicateStrategy:仅作用文件内重复组,不触名称相似 SUSPECT 判定">
|
|
<option value="SKIP">重复策略 SKIP(缺省:文件内重复组全标失败不写入)</option>
|
|
<option value="OVERWRITE">重复策略 OVERWRITE(文件内自上而下最后一条通过校验的行 UPDATE 生效)</option>
|
|
</select>
|
|
<input type="file" id="impFile" accept=".xlsx">
|
|
<button onclick="impUpload()">上传预检</button>
|
|
</div>
|
|
<div class="hint">上传后生成 DRAFT 任务并返回行级预检计数(响应回显 duplicateStrategy);确认后异步执行,任务列表可轮询结果/失败明细。67013=预检失败(mode/策略非法/表头/空文件/行数),67014=状态机不允许(重复 confirm)。F8:duplicateStrategy 仅作用文件内重复组,不触名称相似 SUSPECT 判定。</div>
|
|
</div>
|
|
<div class="panel"><b>导入任务(仅本人可见,creator 过滤)</b>
|
|
<table><thead><tr>
|
|
<th>任务ID</th><th>模式</th><th>状态</th><th>总数</th><th>新增</th><th>更新</th><th>未变化</th><th>失败</th><th>疑似</th><th>失败原因</th><th>提交时间</th><th>操作</th>
|
|
</tr></thead><tbody id="impBody"></tbody></table>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ========== Tab 4 客户交割 ========== -->
|
|
<section id="tab-transfer" style="display:none">
|
|
<div class="panel">
|
|
<b>发起交割<span class="mark" title="67011=无可交接客户/接收总监不合法/非本人发起;67010=存在未完成交接单防重入;整批原子划转 owner 并联动商机三列">A4-7</span></b>
|
|
<div class="toolbar">
|
|
<button onclick="transferPreview()">预览名下待交接客户</button>
|
|
<select id="trReason">
|
|
<option value="resign">离职(自动推导唯一接收总监 D22)</option>
|
|
<option value="transfer_post">岗位调动</option>
|
|
<option value="region_adjust">区域调整</option>
|
|
</select>
|
|
<input id="trDirector" placeholder="接收总监 userId(调动/区域必填)" style="width:230px">
|
|
<input id="trRemark" placeholder="备注(可空)" style="width:160px">
|
|
<button onclick="transferInitiate()">发起</button>
|
|
</div>
|
|
<div class="hint" id="trHint">以当前登录用户为离职/调动人。reason=resign 时接收总监按部门 leader 自动推导。</div>
|
|
</div>
|
|
<div class="panel"><b>交接单列表</b>
|
|
<table><thead><tr>
|
|
<th>交接编号</th><th>发起人</th><th>接收总监</th><th>原因</th><th>状态</th><th>进度</th><th>操作</th>
|
|
</tr></thead><tbody id="trBody"></tbody></table>
|
|
<div class="pager">
|
|
<button onclick="transferPage(-1)">上一页</button><span id="trPageInfo">-</span><button onclick="transferPage(1)">下一页</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel" id="trDetailPanel" style="display:none"><b id="trDetailTitle">交接单详情</b><div id="trDetailBox"></div></div>
|
|
</section>
|
|
|
|
<!-- ========== Tab 5 设置 ========== -->
|
|
<section id="tab-settings" style="display:none">
|
|
<div class="panel">
|
|
<b>超期提醒配置(A7-3-3-1,crm-rule 单例)</b>
|
|
<div class="dbody" style="padding:8px 0">
|
|
<label><b>总开关</b><select id="rMaster"><option value="1">启用</option><option value="0">停用</option></select></label>
|
|
<label><b>首次触发</b><select id="rFirstEn"><option value="1">启用</option><option value="0">停用</option></select>
|
|
超期 <input id="rFirstDays" type="number" min="1" style="width:70px"> 天提醒(默认 30)</label>
|
|
<label><b>二次提醒</b><select id="rSecondEn"><option value="1">启用</option><option value="0">停用</option></select>
|
|
间隔 <input id="rSecondDays" type="number" min="1" style="width:70px"> 天循环(默认 7)</label>
|
|
<div class="hint">保存为全量覆盖:开关字段缺省按 0(禁用)处理;天数非正整数报 64023 且不落库。Job 每轮只补「下一条未建到期提醒」(升级提醒链),锚点=最近有效跟进时间,follow 刷新锚点后旧未发提醒自动置已失效。</div>
|
|
</div>
|
|
<div class="toolbar"><button onclick="loadReminder()">刷新</button><button class="primary" onclick="saveReminder()" style="background:#409eff;color:#fff;border-color:#409eff">保存</button></div>
|
|
</div>
|
|
<div class="panel">
|
|
<b>查重设置(A7-3-3-2,crm-rule 单例,返工票 04 补齐)</b>
|
|
<div class="dbody" style="padding:8px 0">
|
|
<label><b>总开关</b><select id="dpMaster"><option value="1">启用</option><option value="0">停用</option></select></label>
|
|
<label><b>名称查重</b><select id="dpName"><option value="1">启用</option><option value="0">停用</option></select>
|
|
匹配方式 <select id="dpMode"><option value="1">精确(trim+大小写不敏感全等)</option><option value="2">模糊(ngram)</option></select></label>
|
|
<label><b>相似阈值</b><input id="dpThreshold" type="number" min="0" max="100" style="width:80px">(0–100,出厂 80)</label>
|
|
<label><b>电话查重</b><select id="dpPhone"><option value="1">启用</option><option value="0">停用</option></select>
|
|
<span class="hint">软提示不阻断(D26),contact check-phone 受此开关控制</span></label>
|
|
<div class="hint">保存=全量覆盖即生效:开关缺省视为关、方式缺省→模糊、阈值缺省→80;越界 64023 不落库。信用代码查重为系统强制规则(无配置列、消费侧恒开),不在此表单。驱动新增/编辑内建查重(导入路径维持模糊)。</div>
|
|
</div>
|
|
<div class="toolbar"><button onclick="loadDedup()">刷新</button><button class="primary" onclick="saveDedup()" style="background:#409eff;color:#fff;border-color:#409eff">保存</button></div>
|
|
</div>
|
|
<div class="panel">
|
|
<b>查重试玩(消费侧试玩 · 配置走上方查重设置单例;信用代码恒开无配置)</b>
|
|
<div class="toolbar">
|
|
<input id="ckName" placeholder="客户名称" style="width:220px"><button onclick="checkName()">check-name</button>
|
|
<input id="ckCredit" placeholder="统一社会信用代码" style="width:220px"><button onclick="checkCredit()">check-credit-code</button>
|
|
<input id="ckPhone" placeholder="联系人手机号" style="width:180px"><button onclick="checkPhone()">check-phone</button>
|
|
</div>
|
|
<div class="hint" id="ckOut"></div>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<!-- ===== 联系人快速添加弹窗(票02 quickAdd,原型 u193;部分成功语义) ===== -->
|
|
<div class="overlay" id="qaOverlay">
|
|
<div class="modal">
|
|
<div class="modal-head"><h3>快速添加联系人 - <span id="qaCustName"></span></h3><button class="modal-close" onclick="qaClose()">×</button></div>
|
|
<div class="modal-body">
|
|
<table id="qaTable">
|
|
<thead><tr><th style="width:32px">#</th><th>*姓名</th><th>*职务类别</th><th>*职务</th><th>*联系电话</th><th>关键联系人</th><th>来源</th><th>确认知悉</th></tr></thead>
|
|
<tbody id="qaTbody"></tbody>
|
|
</table>
|
|
<span class="qa-hint" id="qaHint">多行快速添加:合法行落库、非法行提示;手机号查重/同姓名同职务疑似重复需勾选「确认知悉」后重提;任一行成功即 bump 图谱版本。</span>
|
|
</div>
|
|
<div class="modal-foot">
|
|
<button class="btn" onclick="qaAddRow()">加一行</button>
|
|
<button class="btn" onclick="qaClose()">取消</button>
|
|
<button class="btn btn-primary" onclick="qaSubmit()">提交</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- ===== 联系人导入三段式弹窗(票02 import/* 六端点,镜像客户导入;id 加 cimp 前缀避让客户导入 tab) ===== -->
|
|
<div class="overlay" id="cimpOverlay">
|
|
<div class="modal">
|
|
<div class="modal-head"><h3>导入联系人 - <span id="cimpCustName"></span></h3><button class="modal-close" onclick="cimpClose()">×</button></div>
|
|
<div class="modal-body">
|
|
<div id="cimpStep1">
|
|
<div class="viewbar">
|
|
<button class="btn" onclick="cimpTemplateDownload()">下载模板(联系人单 sheet 五列)</button>
|
|
<select id="cimpMode"><option value="APPEND_ONLY">APPEND_ONLY 仅新增</option><option value="UPDATE_ONLY">UPDATE_ONLY 仅更新</option><option value="UPSERT">UPSERT 有则更新</option></select>
|
|
<select id="cimpStrategy"><option value="SKIP">重复策略 SKIP(文件内重复组全标失败)</option><option value="OVERWRITE">重复策略 OVERWRITE(最后一条生效)</option></select>
|
|
<input type="file" id="cimpFile" accept=".xlsx">
|
|
<button class="btn btn-primary" id="cimpUploadBtn" onclick="cimpUpload()">上传预检</button>
|
|
</div>
|
|
</div>
|
|
<div id="cimpStep2" style="display:none">
|
|
<div id="cimpPreviewHead" class="hint" style="margin-bottom:8px"></div>
|
|
<table><thead><tr><th>行号</th><th>姓名/电话</th><th>判定</th><th>说明</th></tr></thead><tbody id="cimpPreviewTbody"></tbody></table>
|
|
<div class="modal-foot" style="padding:12px 0 0">
|
|
<button class="btn" onclick="cimpReupload()">重新上传</button>
|
|
<button class="btn btn-primary" id="cimpConfirmBtn" onclick="cimpConfirmTask()">确认导入</button>
|
|
</div>
|
|
</div>
|
|
<div id="cimpStep3" style="display:none">
|
|
<div id="cimpResultHead" class="hint" style="margin-bottom:8px"></div>
|
|
<table><thead><tr><th>行号</th><th>姓名</th><th>电话</th><th>结果</th></tr></thead><tbody id="cimpFailTbody"></tbody></table>
|
|
<div class="modal-foot" style="padding:12px 0 0"><button class="btn btn-primary" onclick="cimpDone()">完成</button></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div id="toast"></div>
|
|
|
|
<dialog id="dlg">
|
|
<div class="dhead" id="dlgTitle"></div>
|
|
<div class="dbody" id="dlgBody"></div>
|
|
<div class="dfoot" id="dlgFoot"></div>
|
|
</dialog>
|
|
|
|
<script>
|
|
/* ================= 常量与工具 ================= */
|
|
const API_BASE = (() => { // 默认直连测试环境(双击即验收);同源部署(nginx 子路径)留空;本地起服自测用 ?api=http://localhost:8080,?api=reset 清记忆回默认
|
|
const q = new URLSearchParams(location.search).get('api');
|
|
if (q !== null) {
|
|
if (q && q !== 'reset') localStorage.setItem('e2c_api', q);
|
|
else localStorage.removeItem('e2c_api');
|
|
}
|
|
const saved = localStorage.getItem('e2c_api');
|
|
if (saved) return saved;
|
|
return location.protocol.startsWith('http') ? '' : 'https://ai.itc.vip/crm-api';
|
|
})();
|
|
const USERS = [
|
|
{ id: '739564171091247104', name: '罗伟健(admin)' },
|
|
{ id: '744842318024015872', name: '曾偲青(BUDDY)' },
|
|
];
|
|
const STAGE = { 1: '潜在客户', 2: '重潜客户', 3: '已成交' };
|
|
const IMP_ST = { 0: 'DRAFT', 1: 'RUNNING', 2: 'DONE', 3: 'FAILED' };
|
|
const TR_ST = { 0: '待分配', 1: '已完成' };
|
|
const REASON = { resign: '离职', transfer_post: '岗位调动', region_adjust: '区域调整' };
|
|
const $ = id => document.getElementById(id);
|
|
const esc = s => s == null ? '' : String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
const TOKEN = () => localStorage.getItem('e2c_token');
|
|
const ME = () => { try { return JSON.parse(localStorage.getItem('e2c_me') || 'null'); } catch (e) { return null; } };
|
|
const codeSuffix = e => e && e.code ? ' (code=' + e.code + ')' : '';
|
|
|
|
function toast(msg, type) {
|
|
const d = document.createElement('div');
|
|
d.className = 'toast-item toast-' + (type === 'ok' ? 'ok' : type === 'err' ? 'err' : 'info');
|
|
d.textContent = msg;
|
|
$('toast').appendChild(d);
|
|
setTimeout(() => d.remove(), 4500);
|
|
}
|
|
function qs(o) {
|
|
return Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== '')
|
|
.map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v)).join('&');
|
|
}
|
|
async function api(path, opt = {}) {
|
|
const headers = opt.headers || (opt.headers = {});
|
|
if (!(opt.body instanceof FormData)) headers['Content-Type'] = headers['Content-Type'] || 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
if (TOKEN()) headers['Authorization'] = 'Bearer ' + TOKEN();
|
|
const r = await fetch(API_BASE + path, opt);
|
|
let j = null;
|
|
try { j = await r.json(); } catch (e) {}
|
|
if (r.status === 401) { toast('未登录或 token 失效,请先在页头登录', 'err'); throw new Error('401 未授权'); }
|
|
if (j && j.code !== 0 && j.code !== undefined) {
|
|
if (j.code === 40401 || r.status === 404) toast('接口不存在(404):' + path, 'err');
|
|
const e = new Error(j.message || ('HTTP ' + r.status)); e.code = j.code; e.data = j.data; throw e;
|
|
}
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return j ? j.data : null;
|
|
}
|
|
|
|
async function apiDownload(path, method) { // 二进制直出(联系人导出/模板):失败时服务端回信封 JSON,成功返回 blob
|
|
const opt = { method: method || 'GET' };
|
|
if (TOKEN()) opt.headers = { Authorization: 'Bearer ' + TOKEN() };
|
|
const res = await fetch(API_BASE + path, opt);
|
|
const ct = res.headers.get('Content-Type') || '';
|
|
if (ct.indexOf('json') >= 0) {
|
|
const body = await res.json();
|
|
const e = new Error(body.message || '下载失败'); e.code = body.code; throw e;
|
|
}
|
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
return res.blob();
|
|
}/* ================= 登录 ================= */
|
|
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('e2c_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('e2c_me', JSON.stringify(me));
|
|
paintWho();
|
|
toast('登录成功: ' + (me.userName || me.nickName || uid), 'ok');
|
|
S.page = 1; loadList(); loadSummary();
|
|
} catch (e) { toast('登录异常: ' + e.message, 'err'); }
|
|
}
|
|
function switchUser(uid) { $('uidInput').value = uid; loginByUid(); }
|
|
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');
|
|
}
|
|
|
|
/* ================= 客户列表 ================= */
|
|
const S = { ws: 'mine', page: 1, size: 10, total: 0, rows: [], picked: new Set() };
|
|
function switchWs(ws) {
|
|
S.ws = ws; S.page = 1; S.picked.clear(); paintPick();
|
|
document.querySelectorAll('#tab-list .viewbar [data-ws]').forEach(b => b.classList.toggle('on', b.dataset.ws === ws));
|
|
loadList(); loadSummary();
|
|
}
|
|
async function loadList(dir) {
|
|
if (!TOKEN()) { $('listHint').textContent = '请先登录'; return; }
|
|
if (dir === -1) S.page = Math.max(1, S.page - 1);
|
|
else if (dir === 1) S.page += 1;
|
|
$('listHint').textContent = '加载中...';
|
|
try {
|
|
const d = await api('/api/customer/workspace/page', {
|
|
method: 'POST',
|
|
body: qs({ workspace: S.ws, current: S.page, size: S.size, viewType: $('fViewType').value, keyword: $('fKeyword').value.trim() })
|
|
});
|
|
S.rows = (d && d.content) || [];
|
|
S.total = (d && d.total) || 0;
|
|
if (!S.rows.length && S.page > 1) { S.page = 1; return loadList(); }
|
|
$('listBody').innerHTML = S.rows.length ? S.rows.map(rowHtml).join('') : '<tr><td colspan="12" class="empty">无数据</td></tr>';
|
|
$('pageInfo').textContent = '第 ' + S.page + ' 页 / 共 ' + S.total + ' 条';
|
|
$('listHint').textContent = '';
|
|
paintPick();
|
|
} catch (e) { $('listHint').textContent = '加载失败: ' + e.message; }
|
|
}
|
|
function badge(txt, cls) { return '<span class="badge ' + (cls || 'b1') + '">' + esc(txt) + '</span>'; }
|
|
function star(n) { return n ? '★'.repeat(Number(n)) : '--'; }
|
|
function rowHtml(r) {
|
|
const ck = S.picked.has(String(r.id)) ? ' checked' : '';
|
|
return '<tr>' +
|
|
'<td><input type="checkbox" ' + ck + ' onclick="event.stopPropagation();pick(\'' + r.id + '\')"></td>' +
|
|
'<td><span class="star-link' + (Number(r.focusFlag) ? '' : ' off') + '" title="关注/取关" onclick="event.stopPropagation();toggleFocus(\'' + r.id + '\',' + (Number(r.focusFlag) ? 0 : 1) + ')">' + (Number(r.focusFlag) ? '★' : '☆') + '</span></td>' +
|
|
'<td class="row" onclick="openDetail(\'' + r.id + '\')">' + esc(r.customerName) + '</td>' +
|
|
'<td>' + esc(r.customerNo) + '</td>' +
|
|
'<td>' + badge(STAGE[r.customerStage] || r.customerStage, r.customerStage === 3 ? 'b2' : 'b1') + '</td>' +
|
|
'<td>' + star(r.customerStarLevel) + '</td>' +
|
|
'<td>' + esc(r.ownerNameSnapshot || r.ownerUserNameSnapshot || r.ownerUserId) + '</td>' +
|
|
'<td>' + esc(r.ownerDeptNameSnapshot || '--') + '</td>' +
|
|
'<td>' + (r.opportunityCount == null ? '--' : r.opportunityCount) + '</td>' +
|
|
'<td>--</td>' +
|
|
'<td>' + esc(r.lastValidFollowTime || '--') + '</td>' +
|
|
'<td><button onclick="event.stopPropagation();openDetail(\'' + r.id + '\')">详情</button></td></tr>';
|
|
}
|
|
function pick(id) { const k = String(id); if (S.picked.has(k)) S.picked.delete(k); else S.picked.add(k); paintPick(); }
|
|
function paintPick() { $('pickHint').textContent = S.picked.size ? '已勾选 ' + S.picked.size + ' 条:' + [...S.picked].join(', ') : '未勾选'; }
|
|
const pickedIds = () => [...S.picked];
|
|
async function loadSummary() {
|
|
const box = $('summaryBox');
|
|
if (!TOKEN()) { box.textContent = '请先登录'; return; }
|
|
try {
|
|
const d = await api('/api/customer/workspace/board/summary', { method: 'POST', body: qs({ workspace: S.ws, viewType: $('fViewType').value }) });
|
|
const items = Array.isArray(d) ? d : Object.entries(d || {}).map(([k, v]) => ({ key: k, count: (v && typeof v === 'object') ? (v.count != null ? v.count : v.total) : v }));
|
|
box.className = 'sumcards';
|
|
box.innerHTML = items.length ? items.map(it => '<div class="card"><b>' + esc(it.count != null ? it.count : '--') + '</b>' + esc(it.name || it.key || '') + '</div>').join('') : '<span class="hint">空</span>';
|
|
} catch (e) { box.className = 'sumcards hint'; box.textContent = '汇总加载失败: ' + e.message; }
|
|
}
|
|
|
|
/* ================= 联系人图谱/页签增强(票02 收编自 customer-contact-graph demo 修订⑧版) ================= */
|
|
/* 修订⑧冻结口径:等级 1-10(10=顶层)、只有手动边(无自动生成/否决)、公司边 = level==10 前端派生视图不可删、
|
|
布局纯由 level 推导零坐标、graph/save 全量快照 + version CAS(67017 冲突退出编辑并重拉)。
|
|
数据分工:列表(ContactDTO 全字段)= 旧端点 /api/customer/contacts;图谱(脱敏+等级+边)= graph/detail。 */
|
|
const COMPANY_ID = '__company__';
|
|
const CARD_W = 150, CARD_H = 104;
|
|
const COMPANY_POS = { x: 1080, y: 184 };
|
|
const ROW_X = 20, ROW_W = 1910;
|
|
const UNREC_X = 1960, UNREC_W = 420, UNREC_Y = 40, UNREC_H = 1320;
|
|
const LV10_Y = 40, LV9_Y = 328, ROW_STEP = 116;
|
|
function lvRowY(lv) { return LV9_Y + (9 - lv) * ROW_STEP; }
|
|
const ROWS = [];
|
|
for (let lv = 10; lv >= 1; lv--) ROWS.push({ lv: lv, y: lv === 10 ? LV10_Y : lvRowY(lv) });
|
|
/* G = 图谱态;C = 列表态(列表数据源 = 旧端点 ContactDTO 全字段) */
|
|
const G = { contacts: [], edges: [], version: 0, editing: false, pendingAdds: [], pendingDeletes: [], editSnapshot: null, cardPos: {}, cycleHighlight: null, logs: [], view: { zoom: 0.62, panX: 0, panY: 0 }, fitOnce: false, curView: 'list' };
|
|
const C = { list: [], query: { keyword: '', internal: '' }, checked: {}, editing: false, editSnapshot: {}, dirtyRows: {}, revealAll: false };
|
|
let revealed = {}; // id -> true(单点临时明文;列表/图谱共享)
|
|
let JOB_DICT = []; // [{cat, code, jobs:[{name, code, level}]}]
|
|
let SOURCE_DICT = []; // [{code, name}]
|
|
let qaRows = [];
|
|
const wrap = $('graphWrap'), canvasInner = $('canvasInner'), edgeSvg = $('edgeSvg');
|
|
const custName = () => D.cname || ('客户 ' + (D.id || ''));
|
|
const opName = () => { const m = ME(); return (m && (m.userName || m.nickName)) || '当前用户'; };
|
|
function nowStr() { return new Date().toTimeString().slice(0, 8); }
|
|
function addLog(type, text) { G.logs.push({ time: nowStr(), type: type, text: text }); renderGraphLogs(); }
|
|
function maskPhone(p) { return p ? p.slice(0, 3) + '****' + p.slice(-4) : '--'; }
|
|
function byId(id) { return G.contacts.find(c => c.id === id); } // 图谱节点
|
|
function cById(id) { return C.list.find(c => String(c.id) === String(id)); } // 列表行
|
|
function cname(id) { return id === COMPANY_ID ? custName() : (byId(id) || { name: id }).name; }
|
|
function jobCodeOfName(name) { for (const g of JOB_DICT) { const j = (g.jobs || []).find(x => x.name === name); if (j) return j.code; } return ''; }
|
|
function sourceName(code) { const s = SOURCE_DICT.find(x => x.code === code); return s ? s.name : (code || '--'); }
|
|
let dictLoaded = false;
|
|
async function ensureDicts() {
|
|
if (dictLoaded) return;
|
|
const pair = await Promise.all([
|
|
api('/api/dict/item/tree?groupCode=job_title').catch(() => []),
|
|
api('/api/dict/item/enabled-list?groupCode=contact_source').catch(() => [])
|
|
]);
|
|
JOB_DICT = (pair[0] || []).map(g => ({ cat: g.name, code: g.code, jobs: (g.children || []).map(j => ({ name: j.name, code: j.code, level: j.level == null ? null : Number(j.level) })) }));
|
|
SOURCE_DICT = (pair[1] || []).map(s => ({ code: s.code, name: s.name }));
|
|
dictLoaded = true;
|
|
}
|
|
/* 层级变化后一致性清理(用户拍板:同级不应有线;方向规则:上级层级必须 > 下级):
|
|
committed 手动边与 pending 新增中,两端层级相同或父层级 <= 子层级的 → 移除并记日志(修订⑧:edges 全为手动边)。 */
|
|
function sanitizeManualEdges() {
|
|
const bad = e => {
|
|
const p = byId(e.parent), c = byId(e.child);
|
|
return p && c && p.level != null && c.level != null && p.level <= c.level;
|
|
};
|
|
G.pendingAdds = G.pendingAdds.filter(a => {
|
|
if (bad({ parent: a.parent, child: a.child })) {
|
|
addLog('关系', '清除未保存连线:' + cname(a.parent) + ' → ' + cname(a.child) + '(层级变化后不再满足上下级关系)');
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
const removed = G.edges.filter(bad);
|
|
if (removed.length) {
|
|
G.edges = G.edges.filter(e => !bad(e));
|
|
removed.forEach(e => addLog('关系', '删除连线:' + cname(e.parent) + ' → ' + cname(e.child) + '(两端层级变化后不再满足上下级关系,已清除)'));
|
|
}
|
|
}
|
|
/* 保存后有效边集 = edges(去 pendingDeletes) + pendingAdds(全部手动边,无 auto) */
|
|
function effectiveEdges() {
|
|
const del = new Set(G.pendingDeletes);
|
|
const list = G.edges.filter(e => !del.has(e.id)).map(e => ({ id: e.id, parent: e.parent, child: e.child }));
|
|
G.pendingAdds.forEach(a => list.push({ id: 'p_' + a.parent + '_' + a.child, parent: a.parent, child: a.child, pending: true }));
|
|
return list;
|
|
}
|
|
/* 修订⑧ D8:公司边 = 等级 10 → 公司 的派生视图(不入 edges/不入 save 快照,不可删;摘线 = 降级) */
|
|
function derivedCompanyEdges() {
|
|
return G.contacts.filter(c => c.level === 10).map(c => ({ id: 'C>' + c.id, parent: c.id, child: COMPANY_ID, company: true }));
|
|
}
|
|
function hasPair(list, p, c) { return list.some(e => e.parent === p && e.child === c); }
|
|
/* DFS 环检测:返回环节点序列([a,b,c,a])或 null;保存前兜底(连线双闸已挡大部分) */
|
|
function findCycle(list) {
|
|
const adj = {};
|
|
list.forEach(e => (adj[e.parent] = adj[e.parent] || []).push(e.child));
|
|
const color = {}, stack = [];
|
|
let cycle = null;
|
|
function dfs(u) {
|
|
color[u] = 1; stack.push(u);
|
|
for (const v of (adj[u] || [])) {
|
|
if (color[v] === 1) { cycle = stack.slice(stack.indexOf(v)).concat([v]); return true; }
|
|
if (!color[v] && dfs(v)) return true;
|
|
}
|
|
color[u] = 2; stack.pop(); return false;
|
|
}
|
|
for (const id of Object.keys(adj)) { if (!color[id] && dfs(id)) break; }
|
|
return cycle;
|
|
}
|
|
/* 画布坐标 → 落点区域(unrec 优先于 y 判定) */
|
|
function rowAt(x, y) {
|
|
if (x >= UNREC_X) return { kind: 'unrec' };
|
|
for (const r of ROWS) if (y >= r.y - 10 && y <= r.y + CARD_H + 10) return { kind: 'lv', lv: r.lv, y: r.y };
|
|
if (y >= COMPANY_POS.y - 10 && y <= COMPANY_POS.y + CARD_H + 10) return { kind: 'company' };
|
|
return { kind: 'none' };
|
|
}
|
|
/* 定级/撤级统一入口(修订⑦+⑧):有上级边禁调级;有下级边可调但须高于最高下级(公司边不计入);取消等级仍须无任何边 */
|
|
function tryLevelDrop(id, target) {
|
|
const c = byId(id);
|
|
if (!c) return;
|
|
const newLv = target.kind === 'unrec' ? null : target.lv;
|
|
if (newLv === c.level) return;
|
|
const eff = effectiveEdges();
|
|
const parentEdge = eff.find(e => e.child === id);
|
|
const childEdges = eff.filter(e => e.parent === id && e.child !== COMPANY_ID);
|
|
if (parentEdge) {
|
|
toast('「' + c.name + '」有上级连线(' + cname(parentEdge.parent) + '),请先取消上级连线后再调整等级', 'err');
|
|
layoutGraph(); renderEdgesOnly(); return;
|
|
}
|
|
if (newLv == null) {
|
|
if (childEdges.length) {
|
|
toast('「' + c.name + '」存在连线,请先取消所有连线后再取消等级', 'err');
|
|
layoutGraph(); renderEdgesOnly(); return;
|
|
}
|
|
} else if (childEdges.length) {
|
|
const maxChild = Math.max.apply(null, childEdges.map(e => byId(e.child).level));
|
|
const childNames = childEdges.filter(e => byId(e.child).level === maxChild).map(e => cname(e.child)).join('、');
|
|
if (newLv <= maxChild) {
|
|
toast('「' + c.name + '」最高下级为等级 ' + maxChild + '(' + childNames + '),新等级须高于它', 'err');
|
|
layoutGraph(); renderEdgesOnly(); return;
|
|
}
|
|
}
|
|
const from = c.level == null ? '未设置' : c.level;
|
|
c.level = newLv;
|
|
delete G.cardPos[id];
|
|
sanitizeManualEdges();
|
|
layoutGraph();
|
|
if (newLv == null) {
|
|
addLog('数据', '「' + c.name + '」等级已取消(拖回未设置栏,原等级:' + from + ')');
|
|
toast('已取消「' + c.name + '」的等级,移入未设置栏');
|
|
} else {
|
|
addLog('数据', '为「' + c.name + '」设定等级 ' + newLv + '(拖入等级格子,原等级:' + from + ')');
|
|
toast('已为「' + c.name + '」设定等级 ' + newLv);
|
|
}
|
|
renderGraph(); renderGraphState();
|
|
}
|
|
/* 强制按等级归行:等级 10 在公司上方,等级 9..1 在公司下方(9 邻近公司),未定级固定右侧栏 */
|
|
function layoutGraph() {
|
|
canvasInner.style.width = '2400px'; canvasInner.style.height = '1400px';
|
|
renderLevelRows();
|
|
const top = [], byLv = {}, unrec = [];
|
|
G.contacts.forEach(c => {
|
|
if (c.level == null) unrec.push(c);
|
|
else if (c.level === 10) top.push(c);
|
|
else (byLv[c.level] = byLv[c.level] || []).push(c);
|
|
});
|
|
const placeRow = (arr, y) => {
|
|
const L = ROW_X + 100, R = ROW_X + ROW_W;
|
|
const total = arr.length * CARD_W + Math.max(0, arr.length - 1) * 44;
|
|
let x = Math.max(L, (L + R - total) / 2);
|
|
arr.forEach(c => { G.cardPos[c.id] = { x: x, y: y }; x += CARD_W + 44; });
|
|
};
|
|
placeRow(top, LV10_Y);
|
|
const lvs = Object.keys(byLv).map(Number).sort((a, b) => b - a);
|
|
lvs.forEach(lv => placeRow(byLv[lv], lvRowY(lv)));
|
|
const zone = $('unrecZone');
|
|
zone.style.left = UNREC_X + 'px'; zone.style.top = UNREC_Y + 'px'; zone.style.width = UNREC_W + 'px'; zone.style.height = UNREC_H + 'px';
|
|
unrec.forEach((c, i) => {
|
|
G.cardPos[c.id] = { x: UNREC_X + 16 + (i % 2) * 200, y: UNREC_Y + 52 + Math.floor(i / 2) * 130 };
|
|
});
|
|
}
|
|
/* 10 个等级格子横条 + 标签(公司行不画格子) */
|
|
function renderLevelRows() {
|
|
canvasInner.querySelectorAll('.lv-row').forEach(el => el.remove());
|
|
ROWS.forEach(r => {
|
|
const el = document.createElement('div');
|
|
el.className = 'lv-row'; el.dataset.lv = r.lv;
|
|
el.style.left = ROW_X + 'px'; el.style.top = r.y + 'px'; el.style.width = ROW_W + 'px'; el.style.height = CARD_H + 'px';
|
|
el.innerHTML = '<span class="row-label">等级 ' + r.lv + (r.lv === 10 ? ' · 顶层' : '') + '</span>';
|
|
canvasInner.insertBefore(el, $('unrecZone'));
|
|
});
|
|
}
|
|
function renderGraph() {
|
|
canvasInner.querySelectorAll('.gcard:not(.company)').forEach(el => el.remove());
|
|
let companyEl = canvasInner.querySelector('.gcard.company');
|
|
if (!companyEl) {
|
|
companyEl = document.createElement('div');
|
|
companyEl.className = 'gcard company';
|
|
companyEl.innerHTML = '<div class="gname">' + esc(custName()) + '</div><div class="gjob">客户主体 · 公司节点固定不可编辑</div>';
|
|
canvasInner.insertBefore(companyEl, $('unrecZone'));
|
|
}
|
|
companyEl.style.left = COMPANY_POS.x + 'px'; companyEl.style.top = COMPANY_POS.y + 'px';
|
|
G.contacts.forEach(c => {
|
|
const el = document.createElement('div');
|
|
el.className = 'gcard'; el.dataset.id = c.id;
|
|
const p = G.cardPos[c.id] || { x: 0, y: 0 };
|
|
const show = revealed[c.id] || C.revealAll;
|
|
el.innerHTML =
|
|
(c.level === 10 ? '<div class="top-badge">顶层</div>' : '') +
|
|
'<div class="avatar">' + esc((c.name || '?')[0] || '?') + '</div>' +
|
|
'<div class="gname">' + esc(c.name) + '</div>' +
|
|
'<div class="gjob">' + esc(c.job) + '</div>' +
|
|
(c.level != null ? '<div class="lv-chip">' + c.level + '</div>' : '') +
|
|
'<div class="gphone' + (show ? ' revealed' : '') + '">' + (show ? esc(c.phone) : maskPhone(c.phone)) + '</div>' +
|
|
'<div class="dot dot-top" title="作为下级被连"></div><div class="dot dot-bottom" title="从此拖出连线"></div>';
|
|
el.style.left = p.x + 'px'; el.style.top = p.y + 'px';
|
|
canvasInner.insertBefore(el, $('unrecZone'));
|
|
});
|
|
renderEdgeSvg();
|
|
$('unsavedChip').className = 'unsaved-chip' + (G.editing && (G.pendingAdds.length || G.pendingDeletes.length) ? ' show' : '');
|
|
}
|
|
function posOf(id) {
|
|
if (id === COMPANY_ID) return { x: COMPANY_POS.x, y: COMPANY_POS.y };
|
|
return G.cardPos[id] || null;
|
|
}
|
|
function renderEdgesOnly() { renderEdgeSvg(); }
|
|
/* SVG 边渲染(手动边带 × 删除钮;公司边 = 派生视图:虚线浅蓝、无 ×、hover 不变红) */
|
|
function renderEdgeSvg() {
|
|
edgeSvg.innerHTML = '';
|
|
const list = effectiveEdges().concat(derivedCompanyEdges());
|
|
const cycPairs = new Set();
|
|
if (G.cycleHighlight) for (let i = 0; i < G.cycleHighlight.length - 1; i++) cycPairs.add(G.cycleHighlight[i] + '>' + G.cycleHighlight[i + 1]);
|
|
list.forEach(e => {
|
|
const p = posOf(e.parent), c = posOf(e.child);
|
|
if (!p || !c) return;
|
|
const x1 = p.x + CARD_W / 2, y1 = p.y + CARD_H, x2 = c.x + CARD_W / 2, y2 = c.y;
|
|
const my = (y1 + y2) / 2, d = 'M ' + x1 + ' ' + y1 + ' C ' + x1 + ' ' + my + ', ' + x2 + ' ' + my + ', ' + x2 + ' ' + y2;
|
|
const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
g.setAttribute('class', 'edge-g' + (e.company ? ' edge-company' : ''));
|
|
g.dataset.eid = e.id; g.dataset.pair = e.parent + '>' + e.child;
|
|
const isCyc = !e.company && cycPairs.has(e.parent + '>' + e.child);
|
|
const cls = 'edge-line' + (e.pending ? ' edge-pending' : '') + (isCyc ? ' edge-cycle' : '');
|
|
g.innerHTML = '<path class="edge-hit" d="' + d + '"/><path class="' + cls + '" d="' + d + '" fill="none"/>' +
|
|
(e.company ? '' :
|
|
'<g class="edge-del" transform="translate(' + ((x1 + x2) / 2) + ',' + my + ')" style="pointer-events:auto;cursor:pointer">' +
|
|
'<circle r="9" fill="#fff" stroke="#dc2626"/><text y="4" text-anchor="middle" fill="#dc2626" font-size="12" font-weight="bold">×</text></g>');
|
|
edgeSvg.appendChild(g);
|
|
});
|
|
}
|
|
function edgeChip(e, cls) {
|
|
return '<span class="edge-chip ' + cls + '">' + esc(cname(e.parent)) + ' → ' + esc(cname(e.child)) + '</span>';
|
|
}
|
|
function renderGraphState() {
|
|
const delSet = new Set(G.pendingDeletes);
|
|
const delEdges = G.edges.filter(e => delSet.has(e.id));
|
|
$('graphStateBox').innerHTML =
|
|
'<div>committed 手动边(' + G.edges.length + '):' + (G.edges.length ? G.edges.map(e => edgeChip(e, 'manual')).join('') : '无') + '</div>' +
|
|
'<div>pending 新增(' + G.pendingAdds.length + '):' + (G.pendingAdds.length ? G.pendingAdds.map(a => edgeChip({ parent: a.parent, child: a.child }, 'pending')).join('') : '无') + '</div>' +
|
|
'<div>pending 删除(' + G.pendingDeletes.length + '):' + (delEdges.length ? delEdges.map(e => edgeChip(e, 'veto')).join('') : '无') + '</div>' +
|
|
'<div>公司派生边(等级10→公司,' + derivedCompanyEdges().length + '):' + (derivedCompanyEdges().length ? derivedCompanyEdges().map(e => edgeChip(e, 'company')).join('') : '无') + '</div>' +
|
|
'<div>图谱乐观锁版本:' + G.version + (G.editing ? '(编辑中)' : '') + '</div>';
|
|
}
|
|
function renderGraphLogs() {
|
|
const box = $('graphLogBox');
|
|
if (!box) return;
|
|
box.innerHTML = G.logs.length
|
|
? G.logs.slice().reverse().map(l => '<div style="border-left:2px solid #e5e7eb;padding:2px 0 2px 10px;margin-bottom:6px"><span class="hint">' + l.time + '</span> [' + l.type + '] ' + esc(l.text) + '</div>').join('')
|
|
: '暂无日志(保存关系 / 查看明文号码后出现在此)';
|
|
}
|
|
/* 列表视图渲染(数据源 = C.list 全字段;脱敏/reveal 与图谱共享 revealed) */
|
|
function filteredContacts() {
|
|
const kw = (C.query.keyword || '').trim(), internal = C.query.internal;
|
|
return C.list.filter(c => {
|
|
if (internal !== '' && String(Number(c.isInternal) || 0) !== internal) return false;
|
|
if (!kw) return true;
|
|
return (c.name || '').includes(kw) || (c.jobTitleName || '').includes(kw) || (c.phone || '').includes(kw);
|
|
});
|
|
}
|
|
function jobSelectHtml(c) {
|
|
const cur = c._jobCode || jobCodeOfName(c.jobTitleName);
|
|
return JOB_DICT.map(g =>
|
|
'<optgroup label="' + esc(g.cat) + '">' +
|
|
g.jobs.map(j => '<option value="' + esc(j.code) + '"' + (cur === j.code ? ' selected' : '') + '>' + esc(j.name) + '</option>').join('') +
|
|
'</optgroup>').join('');
|
|
}
|
|
function ynSel(c, f) {
|
|
const v = Number(c[f]);
|
|
return '<select data-f="' + f + '" data-id="' + c.id + '"><option value="0"' + (!v ? ' selected' : '') + '>否</option><option value="1"' + (v ? ' selected' : '') + '>是</option></select>';
|
|
}
|
|
function renderContactsList() {
|
|
const tbody = $('contactTbody');
|
|
const list = filteredContacts();
|
|
$('contactCount').textContent = '共 ' + C.list.length + ' 位联系人';
|
|
tbody.innerHTML = list.length ? list.map((c, i) => {
|
|
const cid = String(c.id);
|
|
const dirty = !!C.dirtyRows[cid];
|
|
const opBtns = C.editing
|
|
? (dirty ? '<span class="undo-link" data-op="undo" data-id="' + cid + '">撤销修改</span>' : '')
|
|
: '<span class="opt-link" data-op="edit" data-id="' + cid + '">编辑</span>' +
|
|
'<span class="opt-link" data-op="check" data-id="' + cid + '" data-phone="' + esc(c.phone || '') + '">查重</span>' +
|
|
'<span class="opt-link danger" data-op="del" data-id="' + cid + '">删除</span>';
|
|
const show = revealed[cid] || C.revealAll;
|
|
const phoneCell = show
|
|
? '<span class="phone-plain">' + esc(c.phone || '--') + '</span>'
|
|
: '<span class="phone-masked" data-op="reveal" data-id="' + cid + '">' + maskPhone(c.phone) + '</span>';
|
|
const cells = C.editing ? editRowCells(c) : [
|
|
esc(c.name || '--'), esc(c.jobTitleName || '--'), phoneCell,
|
|
Number(c.isKeyContact) ? '是' : '否', Number(c.isInternal) ? '是' : '否',
|
|
esc(sourceName(c.source)), esc(c.giftRemark || '--')
|
|
];
|
|
return '<tr class="' + (dirty ? 'dirty-row' : '') + '" data-id="' + cid + '">' +
|
|
'<td><input type="checkbox" class="row-chk" data-id="' + cid + '"' + (C.checked[cid] ? ' checked' : '') + '></td>' +
|
|
'<td>' + (i + 1) + '</td>' + cells.map(t => '<td>' + t + '</td>').join('') +
|
|
'<td>' + opBtns + '</td></tr>';
|
|
}).join('') : '<tr><td colspan="10" class="empty">无联系人</td></tr>';
|
|
const n = Object.keys(C.checked).filter(k => C.checked[k] && list.some(c => String(c.id) === k)).length;
|
|
$('selInfo').textContent = n ? '已选择 ' + n + ' 个联系人' : '';
|
|
$('chkAll').checked = list.length > 0 && list.every(c => C.checked[String(c.id)]);
|
|
$('btnEditList').textContent = C.editing ? '退出编辑' : '批量编辑';
|
|
$('btnSaveList').style.display = C.editing ? '' : 'none';
|
|
$('btnResetList').style.display = C.editing ? '' : 'none';
|
|
}
|
|
/* 编辑态:单元格 → 行内控件(姓名/职务二级/电话/关键/来源;内线/礼品 batchEdit 不提交 → 只读展示) */
|
|
function editRowCells(c) {
|
|
return [
|
|
'<input type="text" data-f="name" data-id="' + c.id + '" value="' + esc(c.name || '') + '">',
|
|
'<select data-f="jobFull" data-id="' + c.id + '">' + jobSelectHtml(c) + '</select>',
|
|
'<input type="text" data-f="phone" data-id="' + c.id + '" value="' + esc(c.phone || '') + '">',
|
|
ynSel(c, 'isKeyContact'),
|
|
'<span class="hint">' + (Number(c.isInternal) ? '是' : '否') + '(只读)</span>',
|
|
'<select data-f="source" data-id="' + c.id + '"><option value="">--</option>' + SOURCE_DICT.map(s => '<option value="' + esc(s.code) + '"' + (c.source === s.code ? ' selected' : '') + '>' + esc(s.name) + '</option>').join('') + '</select>',
|
|
'<span class="hint" style="white-space:normal">' + esc(c.giftRemark || '--') + '(只读)</span>'
|
|
];
|
|
}
|
|
/* ================= 弹窗框架 ================= */
|
|
function dlgOpen(title, bodyHtml, buttons) {
|
|
$('dlgTitle').textContent = title;
|
|
$('dlgBody').innerHTML = bodyHtml;
|
|
const foot = $('dlgFoot'); foot.innerHTML = '';
|
|
(buttons || []).forEach(b => {
|
|
const btn = document.createElement('button');
|
|
if (b.ok) btn.className = 'ok';
|
|
btn.textContent = b.text; btn.onclick = b.fn; foot.appendChild(btn);
|
|
});
|
|
const c = document.createElement('button'); c.textContent = '关闭'; c.onclick = () => $('dlg').close(); foot.appendChild(c);
|
|
$('dlg').showModal();
|
|
}
|
|
const dlgClose = () => $('dlg').close();
|
|
const errHtml = e => '<div class="errbox">' + esc((e && e.message) || e) + codeSuffix(e) + '</div>';
|
|
const formVal = id => { const el = $(id); return el ? el.value.trim() : ''; };
|
|
|
|
/* ================= 新建 / 编辑 ================= */
|
|
function dlgCreate() {
|
|
dlgOpen('新建客户(三层查重内建:L2 名称相似 → needConfirm 确认后重发)', `
|
|
<label><b>客户名称*</b><input id="cName" placeholder="e2c-demo-客户"></label>
|
|
<label><b>客户类型*</b><input id="cType" placeholder="字典 code"></label>
|
|
<label><b>省/市/区*</b><input id="cProv" placeholder="provinceCode" style="width:100px"><input id="cCity" placeholder="cityCode" style="width:100px"><input id="cDist" placeholder="districtCode" style="width:100px"></label>
|
|
<label><b>行业一级*</b><input id="cInd" placeholder="industryCode"></label>
|
|
<label><b>信用代码</b><input id="cCredit" placeholder="统一社会信用代码(撞码 L3 硬拦 67003)"></label>
|
|
<label><b>客户星/关系星</b><select id="cStar"><option value="1">★1</option><option value="2">★2</option><option value="3" selected>★3</option></select><select id="cRStar"><option value="1">★1</option><option value="2">★2</option><option value="3" selected>★3</option></select></label>
|
|
<label><b>商务谈判</b><select id="cBiz"><option value="0">否</option><option value="1">是</option></select></label>
|
|
<label><b>子客户</b><select id="cChild"><option value="0">否</option><option value="1">是</option></select></label>
|
|
<label><b>负责人</b><input id="cOwner" placeholder="userId(空=进公海记 enter_pool_time)"></label>
|
|
<div id="cOut"></div>`,
|
|
[{ text: '创建', ok: true, fn: () => doCreate('') }]);
|
|
}
|
|
function createForm() {
|
|
return {
|
|
customerName: formVal('cName'), customerType: formVal('cType'),
|
|
provinceCode: formVal('cProv'), cityCode: formVal('cCity'), districtCode: formVal('cDist'),
|
|
industryCode: formVal('cInd'), unifiedCreditCode: formVal('cCredit'),
|
|
customerStarLevel: formVal('cStar'), relationStarLevel: formVal('cRStar'),
|
|
isBizNegotiated: formVal('cBiz'), isChild: formVal('cChild'), ownerUserId: formVal('cOwner'),
|
|
};
|
|
}
|
|
async function doCreate(confirmSimilar) {
|
|
try {
|
|
const d = await api('/api/customer/create', { method: 'POST', body: qs(Object.assign(createForm(), { confirmSimilar: confirmSimilar })) });
|
|
if (d && d.needConfirm) {
|
|
const hits = d.similarHits || [];
|
|
$('cOut').innerHTML = '<div class="simbox">相似命中 ' + hits.length + ' 条:<ul>' + hits.map(h =>
|
|
'<li>' + esc(h.customerName) + '(负责人=' + esc(h.ownerName) + ',' + esc(STAGE[h.customerStage] || h.customerStage || '') + ',' + (h.archiveStatus === 2 ? '已归档' : '有效') + ')</li>').join('') +
|
|
'</ul>仍要创建? <button onclick="doCreate(\'true\')">确认创建(confirmSimilar=true)</button></div>';
|
|
return;
|
|
}
|
|
toast('创建成功 id=' + (d && d.id != null ? d.id : (d && d.customerNo) || JSON.stringify(d)), 'ok'); dlgClose(); loadList();
|
|
} catch (e) { $('cOut').innerHTML = errHtml(e); }
|
|
}
|
|
async function dlgEdit() {
|
|
const id = D.id || pickedIds()[0];
|
|
if (!id) return toast('请先选中客户(详情页或勾选行)', 'err');
|
|
try {
|
|
const d = await api('/api/customer/detail?id=' + id);
|
|
dlgOpen('编辑客户(CAS 冲突返回 67005;归属字段不可改)', `
|
|
<label><b>名称</b><input id="eName" value="${esc(d.customerName)}"></label>
|
|
<label><b>类型</b><input id="eType" value="${esc(d.customerType || '')}"></label>
|
|
<label><b>省/市/区</b><input id="eProv" value="${esc(d.provinceCode || '')}" style="width:100px"><input id="eCity" value="${esc(d.cityCode || '')}" style="width:100px"><input id="eDist" value="${esc(d.districtCode || '')}" style="width:100px"></label>
|
|
<label><b>行业</b><input id="eInd" value="${esc(d.industryCode || '')}"></label>
|
|
<label><b>客户星/关系星</b><input id="eStar" value="${esc(d.customerStarLevel == null ? '' : d.customerStarLevel)}" style="width:60px"><input id="eRStar" value="${esc(d.relationStarLevel == null ? '' : d.relationStarLevel)}" style="width:60px"></label>
|
|
<div class="hint">当前 version=${esc(d.version == null ? '-' : d.version)}(编辑带回做 CAS)</div>
|
|
<div id="eOut"></div>`,
|
|
[{ text: '保存', ok: true, fn: doEdit }]);
|
|
window._editId = id; window._editVer = d.version;
|
|
window._editBiz = d.isBizNegotiated; window._editChild = d.isChild; // 冒烟实证:edit 必带 isBizNegotiated+isChild(DB NOT NULL 无默认,D-05 关联)
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
async function doEdit() {
|
|
const id = window._editId;
|
|
try {
|
|
await api('/api/customer/edit?id=' + id, { method: 'POST', body: qs({
|
|
customerName: formVal('eName'), customerType: formVal('eType'),
|
|
provinceCode: formVal('eProv'), cityCode: formVal('eCity'), districtCode: formVal('eDist'),
|
|
industryCode: formVal('eInd'), customerStarLevel: formVal('eStar'), relationStarLevel: formVal('eRStar'),
|
|
version: window._editVer, isBizNegotiated: window._editBiz, isChild: window._editChild,
|
|
}) });
|
|
toast('保存成功', 'ok'); dlgClose(); loadList(); loadHead(); switchSub('info');
|
|
} catch (e) { $('eOut').innerHTML = errHtml(e); }
|
|
}
|
|
|
|
/* ================= 归属操作(单条 / 批量) ================= */
|
|
async function act(path) {
|
|
const d = await api(path, { method: 'POST' });
|
|
const msg = (d && typeof d === 'object') ? JSON.stringify(d) : String(d == null ? '成功' : d);
|
|
toast(msg.slice(0, 160), 'ok');
|
|
loadList(); if (D.id) loadHead();
|
|
return d;
|
|
}
|
|
async function oneBy(op) {
|
|
const id = pickedIds()[0] || D.id;
|
|
if (!id) return toast('请先选中客户', 'err');
|
|
const path = '/api/customer/' + op + '?id=' + id; // 返工后全部 flat ?id=(focus 族 CustomerFocusController 亦然,对照 API-SUMMARY §2.9/§2.10)
|
|
try { await act(path); } catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
async function batchPost(op) {
|
|
const ids = pickedIds(); if (!ids.length) return toast('请先勾选客户行', 'err');
|
|
try { await act('/api/customer/' + op + '?ids=' + ids.join(',')); } catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
const batchClaim = () => batchPost('claim-batch');
|
|
const batchRelease = () => batchPost('release-pool-batch');
|
|
const batchArchive = () => batchPost('archive-batch');
|
|
const batchFocus = () => batchPost('focus-batch');
|
|
async function toggleFocus(id, target) {
|
|
try {
|
|
await api('/api/customer/' + (target ? 'focus' : 'unfocus') + '?id=' + id, { method: 'POST' });
|
|
toast(target ? '已关注' : '已取关', 'ok');
|
|
loadList(); loadSummary();
|
|
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
function dlgAssign() {
|
|
const id = pickedIds()[0];
|
|
if (!id) return toast('请先勾选客户行', 'err');
|
|
dlgOpen('分配给销售', '<label><b>客户 id</b><input value="' + id + '" disabled></label>' +
|
|
'<label><b>目标 userId</b><input id="asUser" placeholder="分配目标用户 id"></label><div id="asOut"></div>',
|
|
[{ text: '分配', ok: true, fn: async () => {
|
|
try { await act('/api/customer/assign?id=' + id + '&userId=' + formVal('asUser')); dlgClose(); }
|
|
catch (e) { $('asOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
function dlgTransferEntry() { switchTab('transfer'); transferPage(0); }
|
|
|
|
/* ---- 批量工商更新(F6,行级软处置;一期 stub 恒 NO_HIT) ---- */
|
|
const CU_ST = { UPDATED: ['已回填', 'b2'], SKIPPED: ['无空缺', 'b1'], NO_HIT: ['未命中', 'b3'], CONFLICT: ['信用代码冲突', 'b4'], NOT_FOUND: ['客户不存在', 'b4'] };
|
|
async function batchCompanyUpdate() {
|
|
const ids = pickedIds(); if (!ids.length) return toast('请先勾选客户行', 'err');
|
|
if (ids.length > 100) return toast('单次上限 100(当前 ' + ids.length + ')', 'err');
|
|
try {
|
|
const rows = await api('/api/customer/company-lookup/batch-update?ids=' + ids.join(','), { method: 'POST' });
|
|
const list = Array.isArray(rows) ? rows : [];
|
|
dlgOpen('批量工商更新结果(F6 行级软处置)',
|
|
(list.length ? tableHtml(['客户', '结果', '回填字段数', '说明'], list.map(r => {
|
|
const m = CU_ST[r.status];
|
|
return [esc(r.customerName || r.customerId || '--'),
|
|
badge((m && m[0]) || r.status || '--', (m && m[1]) || 'b1'),
|
|
esc(r.filledFields == null ? '--' : r.filledFields),
|
|
'<span style="white-space:normal">' + esc(r.message || '--') + '</span>'];
|
|
})) : '<div class="empty">空结果</div>') +
|
|
'<div class="hint">一期企查查 port 为 stub 恒 NO_HIT(如实呈现);真实企查查接入后自动激活 UPDATED 回填语义。</div>', []);
|
|
loadList();
|
|
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
|
|
/* ================= 客户详情 ================= */
|
|
const D = { id: null, sub: 'info' };
|
|
function openDetailTab() { switchTab('detail'); if (D.id) { loadHead(); switchSub(D.sub); } }
|
|
function openDetail(id) { D.id = String(id); switchTab('detail'); loadHead(); switchSub('info'); }
|
|
function openDetailById() { const v = $('didInput').value.trim(); if (v) openDetail(v); }
|
|
async function loadHead() {
|
|
const box = $('headBox');
|
|
try {
|
|
const d = await api('/api/customer/detail-head?id=' + D.id);
|
|
D.cname = d.customerName;
|
|
$('detailPanel').style.display = '';
|
|
box.innerHTML = '<b>' + esc(d.customerName) + '</b>(' + esc(d.customerNo) + ') ' +
|
|
badge(STAGE[d.customerStage] || d.customerStage, 'b2') + ' ' +
|
|
badge('客户星 ' + star(d.customerStarLevel), 'b5') + ' ' +
|
|
badge(d.archiveStatus === 2 ? '已归档' : '有效', d.archiveStatus === 2 ? 'b4' : 'b1') +
|
|
'<div class="hint">负责人:' + esc(d.ownerUserNameSnapshot || d.ownerUserId) +
|
|
'|最近跟进:' + esc(d.lastFollowSummary || '--') + '(' + esc(d.lastFollowTime || '--') + ')' +
|
|
'|下次跟进:' + esc(d.nextFollowTime || '--') +
|
|
'|商机 ' + (d.opportunityCount == null ? '--' : d.opportunityCount) +
|
|
'|项目 ' + (d.projectCount == null ? '--' : d.projectCount) + '(契约恒 null 显 --)</div>' +
|
|
'<div class="toolbar">' +
|
|
'<button onclick="dlgEdit()">编辑</button><button onclick="dlgFollow()">写跟进</button>' +
|
|
'<button onclick="openQa()">+快速添加</button><button onclick="dlgMemberAdd()">+成员</button>' +
|
|
'<button onclick="oneBy(\'focus\')">关注</button><button onclick="oneBy(\'unfocus\')">取关</button>' +
|
|
'<button onclick="oneBy(\'star\')">重点客户</button><button onclick="oneBy(\'unstar\')">取消重点</button>' +
|
|
'<button onclick="oneBy(\'claim\')">领取</button><button onclick="oneBy(\'release-pool\')">抛公海</button>' +
|
|
'<button onclick="oneBy(\'archive\')">归档</button><button onclick="oneBy(\'restore\')">恢复</button></div>';
|
|
} catch (e) { box.innerHTML = errHtml(e); $('detailPanel').style.display = 'none'; }
|
|
}
|
|
async function switchSub(s) {
|
|
D.sub = s;
|
|
document.querySelectorAll('#detailPanel .subtabs button').forEach(b => b.classList.toggle('on', b.dataset.s === s));
|
|
const box = $('subBox');
|
|
const shell = $('contactsShell');
|
|
if (s === 'contacts') {
|
|
box.style.display = 'none'; shell.style.display = '';
|
|
openContactsTab(); return;
|
|
}
|
|
box.style.display = ''; shell.style.display = 'none';
|
|
if (!D.id) { box.innerHTML = '<div class="empty">请先打开一个客户</div>'; return; }
|
|
box.innerHTML = '<span class="loading">加载中...</span>';
|
|
try {
|
|
if (s === 'info') {
|
|
const d = await api('/api/customer/detail?id=' + D.id);
|
|
box.innerHTML = '<div class="grid">' + grid(d) + '</div>';
|
|
} else if (s === 'follow') {
|
|
const d = await api('/api/customer/follow/page?id=' + D.id + '¤t=1&size=50');
|
|
box.innerHTML = followHtml((d && d.content) || []);
|
|
} else if (s === 'opps') {
|
|
const d = await api('/api/customer/opportunity/page?id=' + D.id);
|
|
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
|
|
box.innerHTML = rows.length ? tableHtml(['商机名称', '阶段', '金额', '负责人', '状态'], rows.map(o =>
|
|
[esc(o.opportunityName || o.name || o.id), esc(o.stageName || o.stage || '--'), esc(o.estimateAmount == null ? '--' : o.estimateAmount), esc(o.ownerUserName || o.ownerName || '--'), esc(o.statusName || o.status || '--')])) : '<div class="empty">无关联商机(port 反查,失败前端显 --)</div>';
|
|
} else if (s === 'agreements') {
|
|
const d = await api('/api/customer/agreement/list?customerId=' + D.id);
|
|
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
|
|
window._agreements = rows;
|
|
box.innerHTML = agreementsHtml(rows);
|
|
} else if (s === 'members') {
|
|
const d = await api('/api/customer/member/list?id=' + D.id);
|
|
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
|
|
box.innerHTML = membersHtml(rows);
|
|
} else if (s === 'oplog') {
|
|
const d = await api('/api/customer/oplog/page?id=' + D.id + '¤t=1&size=50');
|
|
box.innerHTML = oplogHtml((d && d.content) || []);
|
|
}
|
|
} catch (e) { box.innerHTML = errHtml(e); }
|
|
}
|
|
function grid(d) {
|
|
const F = [['客户编号', 'customerNo'], ['客户名称', 'customerName'], ['客户类型', 'customerTypeName|customerType'],
|
|
['阶段', 'customerStageName|customerStage'], ['客户星级', 'customerStarLevel'], ['关系星级', 'relationStarLevel'],
|
|
['战略协议', 'strategicAgreementLevelName|strategicAgreementLevel'], ['VIP 等级', 'vipCustomerLevel'],
|
|
['信用代码', 'creditCode'], ['省/市/区', 'provinceCode|cityCode|districtCode'], ['行业', 'industryName|industryCode'],
|
|
['商务谈判', 'isBizNegotiated'], ['子客户', 'isChild'], ['负责人', 'ownerUserNameSnapshot|ownerUserId'],
|
|
['创建时间', 'createTime'], ['更新时间', 'updateTime']];
|
|
return F.map(p => {
|
|
const v = p[1].split('|').map(k => d[k]).find(x => x !== undefined && x !== null && x !== '');
|
|
return '<div class="kv"><b>' + p[0] + '</b><span>' + esc(v === undefined ? '--' : v) + '</span></div>';
|
|
}).join('');
|
|
}
|
|
function tableHtml(heads, rows) {
|
|
return '<table><thead><tr>' + heads.map(h => '<th>' + h + '</th>').join('') + '</tr></thead><tbody>' +
|
|
(rows.length ? rows.map(r => '<tr>' + r.map(c => '<td>' + c + '</td>').join('') + '</tr>').join('') :
|
|
'<tr><td colspan="' + heads.length + '" class="empty">无数据</td></tr>') + '</tbody></table>';
|
|
}
|
|
/* ---- 联系人:收编为 contactsShell 常驻面板(p4/p6 注入),旧 contactsHtml 退役 ---- */
|
|
function dlgContact(cid) {
|
|
const c = (window._contacts || []).find(x => String(x.id) === String(cid)) || {};
|
|
dlgOpen(cid ? '编辑联系人' : '新建联系人(67008=入参非法/不属于该客户)', `
|
|
<label><b>姓名*</b><input id="ctName" value="${esc(c.name || '')}"></label>
|
|
<label><b>职务名称*</b><input id="ctJob" value="${esc(c.jobTitleName || '')}"></label>
|
|
<label><b>电话</b><input id="ctPhone" value="${esc(c.phone || '')}" placeholder="手机号格式(非格式 67008)"></label>
|
|
<label><b>关键联系人</b><select id="ctKey"><option value="0" ${c.isKeyContact ? '' : 'selected'}>否</option><option value="1" ${c.isKeyContact ? 'selected' : ''}>是</option></select></label>
|
|
<label><b>内线</b><select id="ctInternal"><option value="0" ${c.isInternal ? '' : 'selected'}>否</option><option value="1" ${c.isInternal ? 'selected' : ''}>是</option></select></label>
|
|
<label><b>礼品备注</b><input id="ctGift" value="${esc(c.giftRemark || '')}"></label>
|
|
<label><b>来源</b><select id="ctSource"><option value=""></option><option value="contact_source_01" ${c.source === 'contact_source_01' ? 'selected' : ''}>组会</option><option value="contact_source_02" ${c.source === 'contact_source_02' ? 'selected' : ''}>组局</option><option value="contact_source_03" ${c.source === 'contact_source_03' ? 'selected' : ''}>转介绍</option><option value="contact_source_04" ${c.source === 'contact_source_04' ? 'selected' : ''}>其他</option></select></label>
|
|
<div id="ctOut"></div>`,
|
|
[{ text: '保存', ok: true, fn: async () => {
|
|
const fields = { name: formVal('ctName'), jobTitleName: formVal('ctJob'), phone: formVal('ctPhone'), isKeyContact: formVal('ctKey'), isInternal: formVal('ctInternal'), giftRemark: formVal('ctGift'), source: formVal('ctSource') };
|
|
const bodyObj = cid ? Object.assign({ id: cid, customerId: D.id }, fields) : Object.assign({ customerId: D.id }, fields);
|
|
try { await api(cid ? ('/api/customer/contact/edit?id=' + cid) : '/api/customer/contact/create', { method: 'POST', body: qs(bodyObj) }); toast('联系人已保存', 'ok'); dlgClose(); switchSub('contacts'); }
|
|
catch (e) { $('ctOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
async function delContact(cid) {
|
|
gConfirm('删除联系人', '确认删除该联系人?删除后列表与图谱同步刷新。', async () => {
|
|
try {
|
|
await api('/api/customer/contact/delete?id=' + cid, { method: 'POST' });
|
|
toast('已删除', 'ok');
|
|
if (D.sub === 'contacts') { if (G.editing) doExitEdit(); await Promise.all([loadContacts(), loadGraph()]); layoutGraph(); renderContactsAll(); }
|
|
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
});
|
|
}
|
|
function dlgCheckPhone(phone) {
|
|
dlgOpen('联系人电话查重(check-phone)', '<label><b>电话</b><input id="cpPhone" value="' + esc(phone || '') + '"></label><div id="cpOut"></div>',
|
|
[{ text: '查重', ok: true, fn: async () => {
|
|
try { const d = await api('/api/customer/contact/check-phone?phone=' + encodeURIComponent(formVal('cpPhone')));
|
|
$('cpOut').innerHTML = '<div class="grid">' + grid(d) + '</div>';
|
|
} catch (e) { $('cpOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
/* ---- 跟进 ---- */
|
|
function followHtml(rows) {
|
|
if (!rows.length) return '<div class="toolbar"><button onclick="dlgFollow()">+ 写跟进</button></div><div class="empty">暂无跟进记录</div>';
|
|
const days = {};
|
|
rows.forEach(r => { const day = String(r.followTime || r.createTime || '').slice(0, 10) || '未知日期'; (days[day] = days[day] || []).push(r); });
|
|
return '<div class="toolbar"><button onclick="dlgFollow()">+ 写跟进</button></div>' +
|
|
Object.keys(days).sort().reverse().map(day =>
|
|
'<div class="logday">' + esc(day) + '</div>' + days[day].map(r =>
|
|
'<div class="panel" style="margin-bottom:6px"><b>' + esc(r.followWayName || r.followWay || '--') + '</b> ' + esc(r.createByName || r.createBy || r.operatorName || '') +
|
|
'<div>' + esc(r.followContent || '') + '</div><div class="hint">下次跟进:' + esc(r.nextFollowTime || '--') + '</div></div>').join('')).join('');
|
|
}
|
|
function dlgFollow() {
|
|
dlgOpen('写跟进(append-only;副作用刷新 last_valid_follow_time 锚点)', `
|
|
<label><b>跟进方式</b><select id="fWay"><option value="follow_way_01">电话沟通</option><option value="follow_way_02">上门拜访</option><option value="follow_way_03">微信对接</option><option value="follow_way_04">线上会议</option><option value="follow_way_05">展会沟通</option></select></label>
|
|
<label><b>内容*</b><textarea id="fContent" rows="3"></textarea></label>
|
|
<label><b>下次跟进</b><input id="fNext" type="datetime-local"><span class="hint">D-02:传过去时间实际成功落库(spec 要求 67009)</span></label>
|
|
<div id="fOut"></div>`,
|
|
[{ text: '保存', ok: true, fn: async () => {
|
|
try {
|
|
await api('/api/customer/follow/add?id=' + D.id, { method: 'POST', body: qs({ followWay: formVal('fWay'), followContent: formVal('fContent'), nextFollowTime: formVal('fNext') }) });
|
|
toast('跟进已保存', 'ok'); dlgClose(); switchSub('follow'); loadHead();
|
|
} catch (e) { $('fOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
/* ---- 成员 ---- */
|
|
function membersHtml(rows) {
|
|
const isOwner = m => m.role === 'OWNER' || m.roleType === 'OWNER' || m.roleType === 1 || m.isOwner === true; // 返工后 DTO 字段 role=OWNER/COLLABORATOR
|
|
const t = tableHtml(['成员', '角色', '操作'], rows.map(m =>
|
|
[esc(m.userName || m.memberUserName || m.userId || '--'),
|
|
isOwner(m) ? badge('负责人', 'b2') : badge('协同人', 'b1'),
|
|
isOwner(m) ? '--' : '<button onclick="delMember(\'' + (m.memberUserId || m.userId) + '\')">移除</button>']));
|
|
return '<div class="toolbar"><button onclick="dlgMemberAdd()">+ 添加协同人</button></div>' + t;
|
|
}
|
|
function dlgMemberAdd() {
|
|
dlgOpen('添加协同人(两阶段校验整批拒绝 67016)', '<label><b>userId 列表</b><textarea id="mIds" rows="2" placeholder="逗号分隔多个 userId"></textarea></label><div id="mOut"></div>',
|
|
[{ text: '添加', ok: true, fn: async () => {
|
|
const ids = formVal('mIds').split(/[,,\s]+/).filter(Boolean);
|
|
try { await api('/api/customer/member/add?id=' + D.id, { method: 'POST', body: ids.map(i => 'memberUserIds=' + encodeURIComponent(i)).join('&') }); toast('成员已添加', 'ok'); dlgClose(); switchSub('members'); }
|
|
catch (e) { $('mOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
async function delMember(uid) {
|
|
try { await api('/api/customer/member/remove?id=' + D.id + '&memberUserId=' + uid, { method: 'POST' }); toast('已移除', 'ok'); switchSub('members'); }
|
|
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
/* ---- 操作日志 ---- */
|
|
function oplogHtml(rows) {
|
|
if (!rows.length) return '<div class="empty">暂无日志</div>';
|
|
const days = {};
|
|
rows.forEach(r => { const day = String(r.createTime || '').slice(0, 10) || '未知日期'; (days[day] = days[day] || []).push(r); });
|
|
return Object.keys(days).sort().reverse().map(day =>
|
|
'<div class="logday">' + esc(day) + '</div>' + days[day].map(r =>
|
|
'<div>' + esc(r.createTime || '') + ' ' + badge(r.action, 'b1') + ' ' + esc(r.operatorName || r.operator || '') + ':' + esc(r.detail || '') + '</div>').join('')).join('');
|
|
}
|
|
|
|
/* ---- 战略协议(详情页签 6,返工票 12) ---- */
|
|
const AG_LV = { 1: '一级', 2: '二级', 3: '三级' };
|
|
function agreementsHtml(rows) {
|
|
const t = tableHtml(['等级', '签订金额', '备注', '附件 fileId', '创建时间', '操作'], rows.map(a =>
|
|
[badge(AG_LV[a.agreementLevel] || a.agreementLevel || '--', a.agreementLevel === 3 ? 'b2' : 'b5'),
|
|
esc(a.amount || '--'), esc(a.remark || '--'), esc(a.fileFileId || '--'), esc(a.createTime || '--'),
|
|
'<button onclick="dlgAgreement(\'' + a.id + '\')">编辑</button> <button onclick="agDetail(\'' + a.id + '\')">详情</button> <button onclick="delAgreement(\'' + a.id + '\')">删除</button>']));
|
|
return '<div class="toolbar"><button onclick="dlgAgreement()">+ 新增协议</button>' +
|
|
'<span class="hint">写后同步客户 strategic_agreement_level 快照(删空=未签);oplog 记 AGREEMENT_ADD/EDIT/DELETE;67017=等级缺失越界/协议不存在或无权访问</span></div>' + t;
|
|
}
|
|
function dlgAgreement(aid) {
|
|
const a = (window._agreements || []).find(x => String(x.id) === String(aid)) || {};
|
|
dlgOpen(aid ? '编辑战略协议(归属不可换客户,以库内归属为准)' : '新增战略协议(等级 1~3 必填)', `
|
|
<label><b>协议等级*</b><select id="agLevel"><option value="1" ${a.agreementLevel === 1 ? 'selected' : ''}>一级</option><option value="2" ${a.agreementLevel === 2 ? 'selected' : ''}>二级</option><option value="3" ${a.agreementLevel === 3 ? 'selected' : ''}>三级</option></select></label>
|
|
<label><b>签订金额</b><input id="agAmount" value="${esc(a.amount || '')}" placeholder="文本:数字/区间/含单位,可空"></label>
|
|
<label><b>备注</b><textarea id="agRemark" rows="2">${esc(a.remark || '')}</textarea></label>
|
|
<label><b>附件 fileId</b><input id="agFile" value="${esc(a.fileFileId || '')}" placeholder="crm-file fileId,可空"></label>
|
|
<div id="agOut"></div>`,
|
|
[{ text: '保存', ok: true, fn: async () => {
|
|
const fields = { agreementLevel: formVal('agLevel'), amount: formVal('agAmount'), remark: formVal('agRemark'), fileFileId: formVal('agFile') };
|
|
try {
|
|
if (aid) await api('/api/customer/agreement/edit?id=' + aid, { method: 'POST', body: qs(fields) });
|
|
else await api('/api/customer/agreement/create', { method: 'POST', body: qs(Object.assign({ customerId: D.id }, fields)) });
|
|
toast('协议已保存(客户战略协议等级快照已同步)', 'ok'); dlgClose(); switchSub('agreements');
|
|
} catch (e) { $('agOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
async function agDetail(aid) {
|
|
try {
|
|
const d = await api('/api/customer/agreement/detail?id=' + aid);
|
|
const kv = [['id', d.id], ['所属客户', d.customerName || d.customerId], ['等级', AG_LV[d.agreementLevel] || d.agreementLevel],
|
|
['签订金额', d.amount], ['备注', d.remark], ['附件 fileId', d.fileFileId], ['创建时间', d.createTime], ['更新时间', d.updateTime]];
|
|
dlgOpen('协议详情', '<div class="grid">' + kv.map(p =>
|
|
'<div class="kv"><b>' + p[0] + '</b><span>' + esc(p[1] == null || p[1] === '' ? '--' : p[1]) + '</span></div>').join('') + '</div>', []);
|
|
} catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
async function delAgreement(aid) {
|
|
if (!confirm('确认删除该战略协议?(删空后客户快照回 NULL=未签)')) return;
|
|
try { await api('/api/customer/agreement/delete?id=' + aid, { method: 'POST' }); toast('已删除(客户快照已同步)', 'ok'); switchSub('agreements'); }
|
|
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
|
|
/* ================= 客户导入 ================= */
|
|
async function impUpload() {
|
|
const f = $('impFile').files[0];
|
|
if (!f) return toast('请先选择 .xlsx 文件', 'err');
|
|
try {
|
|
const fd = new FormData();
|
|
fd.append('file', f);
|
|
fd.append('importMode', $('impMode').value);
|
|
fd.append('duplicateStrategy', $('impStrategy').value); // F8:可选参数,SKIP 缺省 / OVERWRITE;响应回显
|
|
const d = await api('/api/customer/import/upload', { method: 'POST', body: fd });
|
|
toast('预检完成 taskId=' + d.taskId + ' 策略=' + (d.duplicateStrategy || '-') + ' t=' + d.totalCount + ' insert=' + d.insertCount + ' update=' + d.updateCount + ' fail=' + d.failCount + ' suspect=' + d.suspectCount, d.failCount ? 'info' : 'ok');
|
|
loadImportPage();
|
|
} catch (e) { toast('预检失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
function impBadge(st) { const m = { 0: 'b1', 1: 'b3', 2: 'b2', 3: 'b4' }; return '<td>' + badge(IMP_ST[st] != null ? IMP_ST[st] : st, m[st] || 'b1') + '</td>'; }
|
|
async function loadImportPage() {
|
|
const b = $('impBody');
|
|
if (!TOKEN()) { b.innerHTML = '<tr><td colspan="12" class="empty">请先登录</td></tr>'; return; }
|
|
try {
|
|
const d = await api('/api/customer/import/page?current=1&size=20');
|
|
const rows = (d && d.content) || [];
|
|
b.innerHTML = rows.length ? rows.map(t => '<tr>' +
|
|
'<td>' + esc(t.taskId) + '</td><td>' + esc(t.importMode || '--') + '</td>' + impBadge(t.status) +
|
|
'<td>' + (t.totalCount == null ? '-' : t.totalCount) + '</td><td>' + (t.insertCount == null ? '-' : t.insertCount) + '</td>' +
|
|
'<td>' + (t.updateCount == null ? '-' : t.updateCount) + '</td><td>' + (t.unchangedCount == null ? '-' : t.unchangedCount) + '</td>' +
|
|
'<td>' + (t.failCount == null ? '-' : t.failCount) + '</td><td>' + (t.suspectCount == null ? '-' : t.suspectCount) + '</td>' +
|
|
'<td style="white-space:normal;max-width:220px">' + esc(t.failReason || '--') + '</td>' +
|
|
'<td>' + esc(t.createTime || '--') + '</td>' +
|
|
'<td>' + (t.status === 0 ? '<button onclick="impConfirm(\'' + t.taskId + '\')">确认执行</button> ' : '') +
|
|
((t.status === 2 || t.status === 3) ? '<button onclick="impResult(\'' + t.taskId + '\')">结果</button> <button onclick="impFailures(\'' + t.taskId + '\')">失败明细</button>' : '') + '</td></tr>').join('')
|
|
: '<tr><td colspan="12" class="empty">暂无任务</td></tr>';
|
|
} catch (e) { b.innerHTML = '<tr><td colspan="12">' + errHtml(e) + '</td></tr>'; }
|
|
}
|
|
async function impConfirm(taskId) {
|
|
try { await api('/api/customer/import/confirm?taskId=' + taskId, { method: 'POST' }); toast('已确认执行(异步)', 'ok'); setTimeout(loadImportPage, 1500); }
|
|
catch (e) { toast('失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
async function impResult(taskId) {
|
|
try {
|
|
const d = await api('/api/customer/import/result?taskId=' + taskId);
|
|
dlgOpen('导入结果 ' + taskId, '<div class="grid">' + grid(d) + '</div>', []);
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
async function impFailures(taskId) {
|
|
try {
|
|
const d = await api('/api/customer/import/failures?taskId=' + taskId);
|
|
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
|
|
dlgOpen('失败明细 ' + taskId, rows.length ? tableHtml(['行号', '原因', '原始数据'], rows.map(f =>
|
|
[esc(f.rowNo != null ? f.rowNo : (f.rowNum != null ? f.rowNum : f.rowIndex)),
|
|
'<span style="white-space:normal">' + esc(f.failReason || f.reason || '--') + '</span>',
|
|
'<span style="white-space:normal">' + esc(f.rawData || f.rowData || JSON.stringify(f)) + '</span>']))
|
|
: '<div class="empty">无失败明细(D-04:行异常文本超长致明细写不进 fail 表)</div>', []);
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
|
|
/* ================= 客户交割 ================= */
|
|
const TR = { page: 1, size: 10, total: 0 };
|
|
async function transferPreview() {
|
|
try {
|
|
const d = await api('/api/customer/transfer/preview');
|
|
const rows = Array.isArray(d) ? d : ((d && (d.customers || d.content)) || []);
|
|
dlgOpen('名下待交接客户预览(initiate 圈定=名下全量,无 customerIds 入参)',
|
|
rows.length ? tableHtml(['客户名称', '编号', '负责人'], rows.map(c =>
|
|
[esc(c.customerName || c.id), esc(c.customerNo || '--'), esc(c.ownerUserNameSnapshot || c.ownerName || '--')]))
|
|
: '<div class="empty">无满足条件的待交接客户</div>', []);
|
|
} catch (e) { toast('预览失败: ' + e.message, 'err'); }
|
|
}
|
|
async function transferInitiate() {
|
|
const reason = $('trReason').value;
|
|
const body = { reason };
|
|
const dir = $('trDirector').value.trim();
|
|
if (reason !== 'resign') {
|
|
if (!dir) return toast('岗位调动/区域调整必须填接收总监 userId', 'err');
|
|
body.toDirectorId = dir;
|
|
}
|
|
if ($('trRemark').value.trim()) body.remark = $('trRemark').value.trim();
|
|
try {
|
|
const d = await api('/api/customer/transfer/initiate', { method: 'POST', body: qs(body) });
|
|
toast('交割已发起:' + (d && d.transferNo) + ',圈定 ' + (d && d.totalCount) + ' 条', 'ok');
|
|
transferPage(0);
|
|
} catch (e) { toast('发起失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
async function transferPage(dir) {
|
|
if (dir === -1) TR.page = Math.max(1, TR.page - 1);
|
|
else if (dir === 1) TR.page += 1;
|
|
else if (dir === 0) TR.page = 1;
|
|
try {
|
|
const d = await api('/api/customer/transfer/page?current=' + TR.page + '&size=' + TR.size);
|
|
const rows = (d && d.content) || []; TR.total = (d && d.total) || 0;
|
|
$('trBody').innerHTML = rows.length ? rows.map(t => '<tr>' +
|
|
'<td>' + esc(t.transferNo) + '</td>' +
|
|
'<td>' + esc(t.initiatorName || t.initiatorUserName || t.initiatorUserId || '--') + '</td>' +
|
|
'<td>' + esc(t.receiverDirectorName || t.toDirectorName || t.receiverName || '--') + '</td>' +
|
|
'<td>' + esc(REASON[t.reason] || t.reason || '--') + '</td>' +
|
|
'<td>' + badge(TR_ST[t.status] != null ? TR_ST[t.status] : t.status, t.status === 1 ? 'b2' : 'b3') + '</td>' +
|
|
'<td>' + (t.assignedCount == null ? 0 : t.assignedCount) + '/' + (t.totalCount == null ? 0 : t.totalCount) + '</td>' +
|
|
'<td><button onclick="transferDetail(\'' + t.id + '\')">详情</button></td></tr>').join('')
|
|
: '<tr><td colspan="7" class="empty">暂无交接单</td></tr>';
|
|
$('trPageInfo').textContent = '第 ' + TR.page + ' 页 / 共 ' + TR.total + ' 条';
|
|
} catch (e) { $('trBody').innerHTML = '<tr><td colspan="7">' + errHtml(e) + '</td></tr>'; }
|
|
}
|
|
async function transferDetail(id) {
|
|
try {
|
|
const d = await api('/api/customer/transfer/detail?id=' + id);
|
|
$('trDetailPanel').style.display = '';
|
|
$('trDetailTitle').textContent = '交接单详情 ' + d.transferNo + '(进度 ' + (d.assignedCount == null ? 0 : d.assignedCount) + '/' + (d.totalCount == null ? 0 : d.totalCount) + ',' + (TR_ST[d.status] != null ? TR_ST[d.status] : d.status) + ',全分配自动完成 D23)';
|
|
const items = d.items || d.details || [];
|
|
$('trDetailBox').innerHTML = (items.length ? tableHtml(['客户', '原负责人', '新负责人', '明细状态', '操作'], items.map(it =>
|
|
[esc(it.customerName || it.customerId), esc(it.fromUserName || it.fromOwnerId || '--'), esc(it.toUserName || it.assignUserId || '--'),
|
|
it.status === 1 ? badge('已分配', 'b2') : badge('待分配', 'b3'),
|
|
(it.status === 0 && d.status === 0) ? '<button onclick="dlgAssignOne(\'' + id + '\',\'' + (it.customerId || it.id) + '\')">分配</button>' : '--']))
|
|
: '<div class="empty">无明细</div>') +
|
|
'<div class="toolbar"><button onclick="loadAssignable(\'' + id + '\')">查看可分配销售列表</button></div>';
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
function dlgAssignOne(tid, cid) {
|
|
dlgOpen('分配明细给销售(行级部分成功 BatchResult)', '<label><b>目标 userId</b><input id="aoUser"></label><div id="aoOut"></div>',
|
|
[{ text: '分配', ok: true, fn: async () => {
|
|
try {
|
|
const d = await api('/api/customer/transfer/assign?id=' + tid + '&customerIds=' + cid + '&assignUserId=' + encodeURIComponent(formVal('aoUser')), { method: 'POST' });
|
|
toast('分配完成:成功 ' + ((d && d.successCount) || 0) + ' 条', 'ok'); dlgClose(); transferDetail(tid); transferPage(0);
|
|
} catch (e) { $('aoOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
async function loadAssignable(tid) {
|
|
try {
|
|
const d = await api('/api/customer/transfer/assignable?id=' + tid);
|
|
const rows = Array.isArray(d) ? d : ((d && (d.content || d.users)) || []);
|
|
dlgOpen('可分配销售', rows.length ? tableHtml(['userId', '姓名', '部门'], rows.map(u =>
|
|
[esc(u.userId || u.id), esc(u.userName || u.name || '--'), esc(u.deptName || '--')])) : '<div class="empty">空列表</div>', []);
|
|
} catch (e) {
|
|
dlgOpen('可分配销售', errHtml(e) + '<div class="hint">若为超时/500:即 D-07(P1)—— assignable 接口稳定超时(E2E 实测 60s×2 ReadTimeout),缺陷验证模式登记。</div>', []);
|
|
}
|
|
}
|
|
|
|
/* ================= 设置 ================= */
|
|
async function loadReminder() {
|
|
if (!TOKEN()) return toast('请先登录', 'err');
|
|
try {
|
|
const d = await api('/api/rule/customer/reminder');
|
|
$('rMaster').value = d.masterEnabled ? '1' : '0';
|
|
$('rFirstEn').value = d.firstTriggerEnabled ? '1' : '0';
|
|
$('rFirstDays').value = d.firstTriggerDays != null ? d.firstTriggerDays : 30;
|
|
$('rSecondEn').value = d.secondIntervalEnabled ? '1' : '0';
|
|
$('rSecondDays').value = d.secondIntervalDays != null ? d.secondIntervalDays : 7;
|
|
toast('提醒配置已加载', 'ok');
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
async function saveReminder() {
|
|
try {
|
|
await api('/api/rule/customer/reminder/save', {
|
|
method: 'POST', body: qs({
|
|
masterEnabled: +$('rMaster').value, firstTriggerEnabled: +$('rFirstEn').value, firstTriggerDays: +$('rFirstDays').value,
|
|
secondIntervalEnabled: +$('rSecondEn').value, secondIntervalDays: +$('rSecondDays').value,
|
|
})
|
|
});
|
|
toast('已保存(全量覆盖,开关缺省按禁用;天数非正整数 64023)', 'ok');
|
|
} catch (e) { toast('保存失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
/* ---- 查重设置(A7-3-3-2,crm-rule 单例,返工票 04 补齐) ---- */
|
|
async function loadDedup() {
|
|
if (!TOKEN()) return toast('请先登录', 'err');
|
|
try {
|
|
const d = await api('/api/rule/customer/dedup');
|
|
$('dpMaster').value = d.masterEnabled ? '1' : '0';
|
|
$('dpName').value = d.nameEnabled ? '1' : '0';
|
|
$('dpMode').value = String(d.nameMatchMode || 2);
|
|
$('dpThreshold').value = d.similarityThreshold != null ? d.similarityThreshold : 80;
|
|
$('dpPhone').value = d.phoneEnabled ? '1' : '0';
|
|
toast('查重设置已加载', 'ok');
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
async function saveDedup() {
|
|
try {
|
|
await api('/api/rule/customer/dedup/save', {
|
|
method: 'POST', body: qs({
|
|
masterEnabled: +$('dpMaster').value, nameEnabled: +$('dpName').value,
|
|
nameMatchMode: +$('dpMode').value, similarityThreshold: $('dpThreshold').value.trim(),
|
|
phoneEnabled: +$('dpPhone').value,
|
|
})
|
|
});
|
|
toast('查重设置已保存(全量覆盖即生效;越界 64023)', 'ok');
|
|
} catch (e) { toast('保存失败: ' + e.message + codeSuffix(e), 'err'); }
|
|
}
|
|
async function checkName() {
|
|
try { const d = await api('/api/customer/check-name?name=' + encodeURIComponent($('ckName').value)); $('ckOut').innerHTML = 'check-name:' + esc(JSON.stringify(d)); }
|
|
catch (e) { $('ckOut').innerHTML = errHtml(e); }
|
|
}
|
|
async function checkCredit() {
|
|
try { const d = await api('/api/customer/check-credit-code?creditCode=' + encodeURIComponent($('ckCredit').value)); $('ckOut').innerHTML = 'check-credit-code:' + esc(JSON.stringify(d)); }
|
|
catch (e) { $('ckOut').innerHTML = errHtml(e); }
|
|
}
|
|
async function checkPhone() {
|
|
try { const d = await api('/api/customer/contact/check-phone?phone=' + encodeURIComponent($('ckPhone').value)); $('ckOut').innerHTML = 'check-phone:' + esc(JSON.stringify(d)); }
|
|
catch (e) { $('ckOut').innerHTML = errHtml(e); }
|
|
}
|
|
|
|
/* ---- 通用确认 / 未保存三选(主 demo <dialog> 版;「关闭」按钮由 dlgOpen 追加,等同继续编辑) ---- */
|
|
function gConfirm(title, text, onOk) {
|
|
dlgOpen(title, '<div style="line-height:1.7">' + text + '</div>', [
|
|
{ text: '取消', fn: () => dlgClose() },
|
|
{ text: '确定', ok: true, fn: () => { dlgClose(); onOk(); } }
|
|
]);
|
|
}
|
|
function gUnsavedDlg(handlers) {
|
|
dlgOpen('未保存提醒', '<div style="line-height:1.7">您有未保存的更改,请选择后续操作:</div>', [
|
|
{ text: '继续编辑', fn: () => dlgClose() },
|
|
{ text: '放弃修改', fn: () => { dlgClose(); handlers.discard(); } },
|
|
{ text: '保存修改', ok: true, fn: () => { dlgClose(); handlers.save(); } }
|
|
]);
|
|
}
|
|
/* ---- 画布变换(平移 / 缩放 / 适应) ---- */
|
|
function applyTransform() {
|
|
canvasInner.style.transform = 'translate(' + G.view.panX + 'px,' + G.view.panY + 'px) scale(' + G.view.zoom + ')';
|
|
$('zoomLabel').textContent = Math.round(G.view.zoom * 100) + '%';
|
|
}
|
|
function toCanvas(cx, cy) {
|
|
const r = wrap.getBoundingClientRect();
|
|
return { x: (cx - r.left - G.view.panX) / G.view.zoom, y: (cy - r.top - G.view.panY) / G.view.zoom };
|
|
}
|
|
function zoomAt(factor, anchorClientX, anchorClientY) {
|
|
const old = G.view.zoom;
|
|
const nz = Math.max(0.3, Math.min(2, old * factor));
|
|
if (nz === old) return;
|
|
const r = wrap.getBoundingClientRect();
|
|
const ax = (anchorClientX == null ? r.width / 2 : anchorClientX - r.left);
|
|
const ay = (anchorClientY == null ? r.height / 2 : anchorClientY - r.top);
|
|
G.view.panX = ax - (ax - G.view.panX) * (nz / old);
|
|
G.view.panY = ay - (ay - G.view.panY) * (nz / old);
|
|
G.view.zoom = nz; applyTransform();
|
|
}
|
|
function fitView() {
|
|
if (!G.contacts.length) { applyTransform(); return; }
|
|
const xs = G.contacts.map(c => (G.cardPos[c.id] || { x: 0 }).x).concat([COMPANY_POS.x]);
|
|
const ys = G.contacts.map(c => (G.cardPos[c.id] || { y: 0 }).y).concat([COMPANY_POS.y]);
|
|
const minX = Math.min.apply(null, xs), maxX = Math.max.apply(null, xs) + CARD_W;
|
|
const minY = Math.min.apply(null, ys), maxY = Math.max.apply(null, ys) + CARD_H;
|
|
const zone = $('unrecZone');
|
|
const zoneBottom = zone.offsetTop + zone.offsetHeight;
|
|
const wr = wrap.getBoundingClientRect();
|
|
const bw = maxX - minX, bh = Math.max(maxY, zoneBottom) - minY;
|
|
G.view.zoom = Math.max(0.3, Math.min(2, Math.min((wr.width - 50) / bw, (wr.height - 50) / bh)));
|
|
G.view.panX = (wr.width - bw * G.view.zoom) / 2 - minX * G.view.zoom;
|
|
G.view.panY = (wr.height - bh * G.view.zoom) / 2 - minY * G.view.zoom;
|
|
applyTransform();
|
|
}
|
|
/* ---- 图谱编辑模式(进入=快照基准;保存成功后基准刷新为已保存状态) ---- */
|
|
function snapshotGraph() {
|
|
return { edges: JSON.parse(JSON.stringify(G.edges)), pos: JSON.parse(JSON.stringify(G.cardPos)),
|
|
version: G.version, levels: Object.fromEntries(G.contacts.map(c => [c.id, c.level])) };
|
|
}
|
|
function paintGraphEditButtons() {
|
|
$('btnGraphEdit').textContent = G.editing ? '退出编辑' : '编辑图谱';
|
|
$('btnGraphSave').style.display = G.editing ? '' : 'none';
|
|
$('btnGraphReset').style.display = G.editing ? '' : 'none';
|
|
$('hintBar').classList.toggle('show', G.editing);
|
|
wrap.classList.toggle('editing', G.editing);
|
|
}
|
|
function enterEdit() {
|
|
G.editing = true;
|
|
G.pendingAdds = []; G.pendingDeletes = []; G.cycleHighlight = null;
|
|
G.editSnapshot = snapshotGraph();
|
|
document.body.classList.add('graph-edit');
|
|
paintGraphEditButtons();
|
|
renderGraph(); renderGraphState();
|
|
toast('已进入图谱编辑:拖卡片定级/改级,拖圆点连线,悬停连线删边');
|
|
}
|
|
function doExitEdit() {
|
|
G.editing = false;
|
|
G.pendingAdds = []; G.pendingDeletes = []; G.cycleHighlight = null;
|
|
document.body.classList.remove('graph-edit');
|
|
paintGraphEditButtons();
|
|
hideBubble();
|
|
renderGraph(); renderGraphState();
|
|
}
|
|
function restoreLevels() {
|
|
const lv = (G.editSnapshot && G.editSnapshot.levels) || {};
|
|
G.contacts.forEach(c => { if (c.id in lv) c.level = lv[c.id]; });
|
|
}
|
|
function discardGraphEdit() {
|
|
if (G.editSnapshot) {
|
|
G.edges = JSON.parse(JSON.stringify(G.editSnapshot.edges));
|
|
G.cardPos = JSON.parse(JSON.stringify(G.editSnapshot.pos));
|
|
G.version = G.editSnapshot.version;
|
|
restoreLevels();
|
|
}
|
|
doExitEdit();
|
|
toast('已放弃本次修改,恢复到进入编辑时的状态');
|
|
}
|
|
function graphEditToggle() { if (G.editing) exitEditAttempt(); else enterEdit(); }
|
|
function exitEditAttempt(after) {
|
|
const dirty = G.pendingAdds.length || G.pendingDeletes.length;
|
|
if (!dirty) { doExitEdit(); if (after) after(); return; }
|
|
gUnsavedDlg({
|
|
discard: () => { discardGraphEdit(); if (after) after(); },
|
|
save: () => { saveGraph().then(ok => { if (ok) { doExitEdit(); if (after) after(); } }); }
|
|
});
|
|
}
|
|
/* 全量快照 POST graph/save(版本 CAS):nodes 全员等级终态(含未定级空值=撤级)+ 手动边终态集;
|
|
公司边派生不入快照(D8)。67017 冲突 → 退出编辑态并重拉图谱(比原型更稳,原型保留编辑态易叠脏改)。 */
|
|
async function saveGraph() {
|
|
const list = effectiveEdges();
|
|
const cyc = findCycle(list);
|
|
if (cyc) {
|
|
G.cycleHighlight = cyc;
|
|
renderGraph();
|
|
toast('检测到循环关系(' + cyc.slice(0, -1).map(cname).join(' → ') + '),已阻止保存,请修改环边(红色高亮)', 'err');
|
|
return false;
|
|
}
|
|
const adds = G.pendingAdds.slice();
|
|
const delEdges = G.edges.filter(e => G.pendingDeletes.includes(e.id));
|
|
const nextEdges = G.edges.filter(e => !G.pendingDeletes.includes(e.id))
|
|
.concat(adds.map(a => ({ id: a.parent + '>' + a.child, parent: a.parent, child: a.child })));
|
|
const form = new URLSearchParams();
|
|
form.set('customerId', D.id);
|
|
form.set('version', String(G.version));
|
|
G.contacts.forEach((c, i) => {
|
|
form.set('nodes[' + i + '].contactId', c.id);
|
|
form.set('nodes[' + i + '].level', c.level == null ? '' : String(c.level));
|
|
});
|
|
nextEdges.forEach((e, i) => {
|
|
form.set('edges[' + i + '].parentId', e.parent);
|
|
form.set('edges[' + i + '].childId', e.child);
|
|
});
|
|
try {
|
|
const newVer = await api('/api/customer/contact/graph/save', { method: 'POST', body: form.toString() });
|
|
G.version = Number(newVer) || G.version + 1;
|
|
G.edges = nextEdges;
|
|
adds.forEach(a => addLog('关系', '新增手动连线:' + cname(a.parent) + ' → ' + cname(a.child)));
|
|
delEdges.forEach(e => addLog('关系', '删除连线:' + cname(e.parent) + ' → ' + cname(e.child) + '(手动边)'));
|
|
G.pendingAdds = []; G.pendingDeletes = []; G.cycleHighlight = null;
|
|
G.editSnapshot = snapshotGraph();
|
|
toast('保存成功(版本 ' + G.version + '),已记录关系变更日志', 'ok');
|
|
renderContactsList(); renderGraph(); renderGraphState();
|
|
return true;
|
|
} catch (err) {
|
|
toast('保存失败(' + err.code + '):' + err.message + (err.code === 67017 ? ',版本已被他人推进,已退出编辑并重拉图谱' : ''), 'err');
|
|
if (err.code === 67017) { doExitEdit(); loadGraph().then(() => { layoutGraph(); renderContactsList(); renderGraph(); renderGraphState(); }); }
|
|
return false;
|
|
}
|
|
}
|
|
function resetGraphEdit() {
|
|
const dirty = G.pendingAdds.length || G.pendingDeletes.length;
|
|
const doReset = () => {
|
|
if (G.editSnapshot) {
|
|
G.edges = JSON.parse(JSON.stringify(G.editSnapshot.edges));
|
|
G.cardPos = JSON.parse(JSON.stringify(G.editSnapshot.pos));
|
|
G.version = G.editSnapshot.version;
|
|
restoreLevels();
|
|
}
|
|
G.pendingAdds = []; G.pendingDeletes = []; G.cycleHighlight = null;
|
|
toast('已重置:恢复到进入编辑时的初始状态');
|
|
renderGraph(); renderGraphState();
|
|
};
|
|
if (dirty) gConfirm('重置', '确定重置图谱布局吗?所有未保存的调整将恢复到初始状态。', doReset);
|
|
else doReset();
|
|
}
|
|
/* ---- 拖线 / 拖卡 / 平移(mousedown 分流 + document 跟随;bindContactsUI 统一绑定) ---- */
|
|
let dragLine = null, dragCard = null, panning = null;
|
|
function startDragLine(e, fromId, dir) {
|
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
path.setAttribute('class', 'edge-line edge-pending'); path.setAttribute('fill', 'none');
|
|
edgeSvg.appendChild(path);
|
|
dragLine = { from: fromId, dir: dir || 'down', path: path };
|
|
updateDragLine(e);
|
|
}
|
|
function updateDragLine(e) {
|
|
const p = posOf(dragLine.from);
|
|
if (!p) return;
|
|
const sy = dragLine.dir === 'up' ? p.y : p.y + CARD_H;
|
|
const pt = toCanvas(e.clientX, e.clientY);
|
|
dragLine.path.setAttribute('d', 'M ' + (p.x + CARD_W / 2) + ' ' + sy + ' L ' + pt.x + ' ' + pt.y);
|
|
canvasInner.querySelectorAll('.gcard.link-target').forEach(el => el.classList.remove('link-target'));
|
|
const t = document.elementFromPoint(e.clientX, e.clientY);
|
|
const card = t && t.closest ? t.closest('.gcard') : null;
|
|
if (card && !card.classList.contains('company') && card.dataset.id && card.dataset.id !== dragLine.from) card.classList.add('link-target');
|
|
}
|
|
function tryConnect(aId, bId) {
|
|
if (aId === bId) { toast('不允许连接自身(' + cname(aId) + ')', 'err'); return; }
|
|
if (aId === COMPANY_ID || bId === COMPANY_ID) { toast('不允许为公司节点新增或删除关系', 'err'); return; }
|
|
if (hasPair(effectiveEdges(), aId, bId) || hasPair(effectiveEdges(), bId, aId)) {
|
|
toast('已存在连线:' + cname(aId) + ' ↔ ' + cname(bId) + '(蓝色实线即是),换一对联系人试试', 'err'); return;
|
|
}
|
|
const a = byId(aId), b = byId(bId);
|
|
if (!a || !b) return;
|
|
if (a.level == null || b.level == null) {
|
|
toast('「' + cname(a.level == null ? aId : bId) + '」未设置等级,请先将其拖入等级格子完成定级再连线'); return;
|
|
}
|
|
commitDirectionalEdge(aId, bId);
|
|
}
|
|
/* 两端层级已齐:按层级自动定方向(高者为上级;同级不连)+ 单父约束 → 落 pending */
|
|
function commitDirectionalEdge(aId, bId) {
|
|
const a = byId(aId), b = byId(bId);
|
|
if (!a || !b) return false;
|
|
if (a.level === b.level) { toast('同级(层级相同)之间不建立上下级连线', 'err'); return false; }
|
|
const parentId = a.level > b.level ? aId : bId, childId = a.level > b.level ? bId : aId;
|
|
const exParent = effectiveEdges().find(e => e.child === childId);
|
|
if (exParent) { toast(cname(childId) + ' 已有上级(' + cname(exParent.parent) + '),一个节点最多一个上级,请先删除原连线', 'err'); return false; }
|
|
commitPendingEdge(parentId, childId);
|
|
return true;
|
|
}
|
|
function commitPendingEdge(parentId, childId) {
|
|
G.pendingAdds.push({ parent: parentId, child: childId });
|
|
G.cycleHighlight = null;
|
|
addLog('关系', '新增连线(未保存):' + cname(parentId) + ' → ' + cname(childId));
|
|
renderGraph(); renderGraphState();
|
|
}
|
|
function graphMouseDown(e) {
|
|
if (e.button !== 0) return;
|
|
const dot = e.target.closest('.dot');
|
|
if (dot && G.editing) {
|
|
const gc = dot.closest('.gcard');
|
|
if (!gc) return;
|
|
if (dot.classList.contains('dot-bottom')) { e.preventDefault(); startDragLine(e, gc.dataset.id, 'down'); return; }
|
|
if (dot.classList.contains('dot-top')) { e.preventDefault(); startDragLine(e, gc.dataset.id, 'up'); return; }
|
|
}
|
|
const card = e.target.closest('.gcard');
|
|
if (card && G.editing && !card.classList.contains('company') && card.dataset.id) {
|
|
const pt = toCanvas(e.clientX, e.clientY), p = G.cardPos[card.dataset.id];
|
|
if (!p) return;
|
|
dragCard = { id: card.dataset.id, dx: pt.x - p.x, dy: pt.y - p.y };
|
|
e.preventDefault();
|
|
return;
|
|
}
|
|
if (!card) {
|
|
panning = { x: e.clientX, y: e.clientY, px: G.view.panX, py: G.view.panY };
|
|
wrap.classList.add('panning');
|
|
}
|
|
}
|
|
function graphMouseMove(e) {
|
|
if (dragLine) { updateDragLine(e); return; }
|
|
if (dragCard) {
|
|
const pt = toCanvas(e.clientX, e.clientY);
|
|
const p = G.cardPos[dragCard.id];
|
|
if (!p) return;
|
|
p.x = Math.max(0, Math.min(2400 - CARD_W, pt.x - dragCard.dx));
|
|
p.y = Math.max(0, Math.min(1400 - CARD_H, pt.y - dragCard.dy));
|
|
const el = canvasInner.querySelector('.gcard[data-id="' + dragCard.id + '"]');
|
|
if (el) { el.style.left = p.x + 'px'; el.style.top = p.y + 'px'; }
|
|
canvasInner.querySelectorAll('.lv-row.drop-target').forEach(x => x.classList.remove('drop-target'));
|
|
if (G.editing) {
|
|
const t = rowAt(pt.x, pt.y);
|
|
if (t.kind === 'lv') { const row = canvasInner.querySelector('.lv-row[data-lv="' + t.lv + '"]'); if (row) row.classList.add('drop-target'); }
|
|
}
|
|
renderEdgesOnly();
|
|
return;
|
|
}
|
|
if (panning) {
|
|
G.view.panX = panning.px + (e.clientX - panning.x);
|
|
G.view.panY = panning.py + (e.clientY - panning.y);
|
|
applyTransform();
|
|
}
|
|
}
|
|
function graphMouseUp(e) {
|
|
if (dragLine) {
|
|
const t = document.elementFromPoint(e.clientX, e.clientY);
|
|
const card = t && t.closest ? t.closest('.gcard') : null;
|
|
if (card && card.classList.contains('company')) toast('不允许为公司节点新增或删除关系', 'err');
|
|
else if (card && card.dataset.id && card.dataset.id !== dragLine.from) tryConnect(dragLine.from, card.dataset.id);
|
|
dragLine.path.remove();
|
|
dragLine = null;
|
|
canvasInner.querySelectorAll('.gcard.link-target').forEach(el => el.classList.remove('link-target'));
|
|
}
|
|
if (dragCard) {
|
|
const pt = toCanvas(e.clientX, e.clientY);
|
|
const t = rowAt(pt.x, pt.y);
|
|
if (t.kind === 'lv' || t.kind === 'unrec') tryLevelDrop(dragCard.id, t);
|
|
else if (t.kind === 'company') toast('公司行不可放置联系人卡片');
|
|
canvasInner.querySelectorAll('.lv-row.drop-target').forEach(x => x.classList.remove('drop-target'));
|
|
}
|
|
dragCard = null;
|
|
if (panning) { panning = null; wrap.classList.remove('panning'); }
|
|
}
|
|
function graphWheel(e) { e.preventDefault(); zoomAt(e.deltaY < 0 ? 1.12 : 0.9, e.clientX, e.clientY); }
|
|
/* ---- 删边(× 按钮;公司派生边无 × 不可删) ---- */
|
|
function edgeDeleteClick(e) {
|
|
const del = e.target.closest('.edge-del');
|
|
if (!del || !G.editing) return;
|
|
const g = del.closest('g.edge-g');
|
|
if (!g) return;
|
|
const eid = g.dataset.eid, pair = g.dataset.pair.split('>');
|
|
if (eid.indexOf('p_') === 0) {
|
|
G.pendingAdds = G.pendingAdds.filter(a => !(a.parent === pair[0] && a.child === pair[1]));
|
|
toast('已撤销本次新增连线(未保存)');
|
|
renderGraph(); renderGraphState();
|
|
return;
|
|
}
|
|
if (!G.edges.find(x => x.id === eid)) return;
|
|
G.pendingDeletes.push(eid);
|
|
G.cycleHighlight = null;
|
|
addLog('关系', '删除连线(未保存):' + cname(pair[0]) + ' → ' + cname(pair[1]) + '(手动边)');
|
|
renderGraph(); renderGraphState();
|
|
}
|
|
/* ---- 图谱卡片电话点击明文 + 埋点(脱敏拍板:列表与图谱卡片均默认脱敏) ---- */
|
|
function cardPhoneClick(e) {
|
|
const ph = e.target.closest('.gphone');
|
|
if (!ph) return;
|
|
const card = ph.closest('.gcard');
|
|
if (!card || !card.dataset.id) return;
|
|
revealOne(card.dataset.id).then(renderGraph);
|
|
}
|
|
/* ---- 悬停气泡(人脉关系/内线/礼品——节点 DTO 未含,诚实显 --) ---- */
|
|
function hideBubble() { $('bubble').style.display = 'none'; }
|
|
function cardBubbleOver(e) {
|
|
const card = e.target.closest('.gcard');
|
|
if (!card || card.classList.contains('company') || !card.dataset.id) { hideBubble(); return; }
|
|
const c = byId(card.dataset.id);
|
|
if (!c) return;
|
|
const b = $('bubble');
|
|
b.innerHTML = '<div class="b-title">' + esc(c.name) + ' · ' + esc(c.job) + '</div>' +
|
|
'<div class="b-row"><span>人脉关系情况</span><b>--</b></div>' +
|
|
'<div class="b-row"><span>是否内线</span><b>--</b></div>' +
|
|
'<div class="b-row"><span>礼品备注</span><b>--</b></div>';
|
|
const p = G.cardPos[c.id];
|
|
if (!p) return;
|
|
b.style.left = (p.x * G.view.zoom + G.view.panX + CARD_W * G.view.zoom + 12) + 'px';
|
|
b.style.top = (p.y * G.view.zoom + G.view.panY) + 'px';
|
|
b.style.display = 'block';
|
|
}
|
|
/* ---- 列表/图谱 pill 双视图切换(未保存拦截 → 三选;主 demo 按钮 on 类) ---- */
|
|
function switchContactView(v) {
|
|
if (v === G.curView) return;
|
|
const go = () => {
|
|
G.curView = v;
|
|
$('pillList').classList.toggle('on', v === 'list');
|
|
$('pillGraph').classList.toggle('on', v === 'graph');
|
|
$('cListView').style.display = v === 'list' ? '' : 'none';
|
|
$('gView').style.display = v === 'graph' ? '' : 'none';
|
|
if (v === 'graph') { layoutGraph(); if (!G.fitOnce) { fitView(); G.fitOnce = true; } renderGraph(); applyTransform(); }
|
|
else renderContactsList();
|
|
};
|
|
if (G.editing) { exitEditAttempt(go); return; }
|
|
if (C.editing) { exitListEditAttempt(go); return; }
|
|
go();
|
|
}
|
|
/* ---- 数据加载(列表=旧端点 contacts 全字段;图谱=graph/detail;互不吞数据) ---- */
|
|
async function loadContacts() {
|
|
const d = await api('/api/customer/contacts?customerId=' + D.id);
|
|
C.list = (Array.isArray(d) ? d : ((d && d.content) || [])).map(c => Object.assign({}, c, { _jobCode: '', _catCode: '' }));
|
|
C.checked = {}; revealed = {}; C.dirtyRows = {}; C.revealAll = false;
|
|
$('btnRevealAll').textContent = '明文查看';
|
|
window._contacts = C.list; // dlgContact/dlgCheckPhone 旧入口兼容
|
|
C.list.forEach(c => { c._jobCode = jobCodeOfName(c.jobTitleName); });
|
|
}
|
|
function loadGraph() {
|
|
return api('/api/customer/contact/graph/detail?customerId=' + D.id).then(applyGraph);
|
|
}
|
|
function applyGraph(dto) {
|
|
G.version = Number(dto.version) || 0;
|
|
G.contacts = (dto.nodes || []).map(n => ({
|
|
id: String(n.contactId), name: n.name || '', cat: n.jobTitleCategory || '', job: n.jobTitleName || '',
|
|
level: n.level == null ? null : Number(n.level), levelSource: n.levelSource == null ? null : Number(n.levelSource),
|
|
phone: n.phoneMasked || '', key: Number(n.isKeyContact) || 0, internal: null
|
|
}));
|
|
G.edges = (dto.edges || []).map(e => ({ id: String(e.parentId) + '>' + String(e.childId), parent: String(e.parentId), child: String(e.childId) }));
|
|
G.cardPos = {}; G.pendingAdds = []; G.pendingDeletes = []; G.cycleHighlight = null; G.editing = false;
|
|
document.body.classList.remove('graph-edit');
|
|
paintGraphEditButtons();
|
|
}
|
|
/* 联系人子页签入口(switchSub('contacts') 调;双加载后首渲染) */
|
|
async function openContactsTab() {
|
|
await ensureDicts();
|
|
try {
|
|
await Promise.all([loadContacts(), loadGraph()]);
|
|
} catch (e) {
|
|
$('contactTbody').innerHTML = '<tr><td colspan="10"><div class="errbox">' + esc(e.message) + codeSuffix(e) + '</div></td></tr>';
|
|
return;
|
|
}
|
|
layoutGraph();
|
|
renderContactsAll();
|
|
if (G.curView === 'graph') { if (!G.fitOnce) { fitView(); G.fitOnce = true; } applyTransform(); }
|
|
}
|
|
function renderContactsAll() { renderContactsList(); renderGraph(); renderGraphState(); }
|
|
/* ---- 脱敏明文(GET /reveal 服务端记埋点;双写列表行与图谱节点) ---- */
|
|
async function revealOne(id) {
|
|
if (revealed[id]) return;
|
|
try {
|
|
const plain = await api('/api/customer/contact/reveal?id=' + encodeURIComponent(id));
|
|
revealed[id] = true;
|
|
const lc = cById(id); if (lc) lc.phone = plain;
|
|
const gc = byId(id); if (gc) gc.phone = plain;
|
|
addLog('埋点', opName() + ' 于 ' + nowStr() + ' 查看明文号码:' + (gc ? gc.name : id) + '(' + maskPhone(plain) + ')');
|
|
} catch (err) {
|
|
toast('明文获取失败(' + err.code + '):' + err.message, 'err');
|
|
}
|
|
}
|
|
async function toggleRevealAll() {
|
|
C.revealAll = !C.revealAll;
|
|
$('btnRevealAll').textContent = C.revealAll ? '隐藏明文' : '明文查看';
|
|
if (C.revealAll) {
|
|
addLog('埋点', opName() + ' 于 ' + nowStr() + ' 批量显示当前过滤结果完整号码(共 ' + filteredContacts().length + ' 位)');
|
|
for (const c of filteredContacts()) { if (!revealed[c.id]) await revealOne(c.id); }
|
|
}
|
|
renderContactsAll();
|
|
}
|
|
/* ---- 查询(页内过滤;勾选与临时明文重置) ---- */
|
|
function queryContacts() {
|
|
C.query = { keyword: $('qKeyword').value, internal: $('qInternal').value };
|
|
C.checked = {}; revealed = {}; C.revealAll = false;
|
|
$('btnRevealAll').textContent = '明文查看';
|
|
renderContactsList();
|
|
}
|
|
function resetContactsContact() {
|
|
$('qKeyword').value = ''; $('qInternal').value = '';
|
|
C.query = { keyword: '', internal: '' };
|
|
C.checked = {}; revealed = {}; C.revealAll = false;
|
|
$('btnRevealAll').textContent = '明文查看';
|
|
renderContactsList();
|
|
}
|
|
function chkAllChange(el) {
|
|
filteredContacts().forEach(c => { const k = String(c.id); if (el.checked) C.checked[k] = true; else delete C.checked[k]; });
|
|
renderContactsList();
|
|
}
|
|
/* ---- 页内批量编辑(batchEdit:原子批任一失败零落库;字段 null=不改;脱敏形态(含*)提交=不改) ---- */
|
|
function enterListEdit() {
|
|
C.editing = true; C.dirtyRows = {};
|
|
C.editSnapshot = {};
|
|
C.list.forEach(c => { C.editSnapshot[c.id] = JSON.parse(JSON.stringify(c)); });
|
|
renderContactsList();
|
|
toast('已进入页内编辑:可修改姓名/职务/电话/关键/来源;保存走 batchEdit(原子批)');
|
|
}
|
|
function listDirtyCount() { return Object.keys(C.dirtyRows).length; }
|
|
function doExitListEdit() { C.editing = false; C.dirtyRows = {}; renderContactsList(); }
|
|
function resetListEditSilent() {
|
|
Object.keys(C.editSnapshot).forEach(id => {
|
|
const c = cById(id);
|
|
if (c) Object.assign(c, C.editSnapshot[id]);
|
|
});
|
|
C.dirtyRows = {};
|
|
}
|
|
function btnResetListClick() {
|
|
gConfirm('放弃修改', '确定放弃本次全部修改吗?数据将恢复到进入编辑模式时的状态。', () => {
|
|
resetListEditSilent(); doExitListEdit(); toast('已放弃未保存修改');
|
|
});
|
|
}
|
|
function editListToggle() { if (C.editing) exitListEditAttempt(); else enterListEdit(); }
|
|
function exitListEditAttempt(after) {
|
|
if (!listDirtyCount()) { doExitListEdit(); if (after) after(); return; }
|
|
gUnsavedDlg({
|
|
discard: () => { resetListEditSilent(); doExitListEdit(); if (after) after(); toast('已放弃未保存修改'); },
|
|
save: () => { saveListAll().then(ok => { if (ok) { doExitListEdit(); if (after) after(); } }); }
|
|
});
|
|
}
|
|
/* 编辑态单元格 → 数据 + dirty 标记(input 不重渲染以保焦点)。
|
|
修复原型潜伏 bug:职务下拉 value=职务 code,须按「类别下的职务 code」反查(原型按类别 code 反查恒 miss →
|
|
页内改职务静默失效)。D1:职务变更 → 档位字典 level 跟随重算(levelSource=1 由服务端落库)。 */
|
|
function applyCellEdit(input, isInput) {
|
|
const id = input.dataset.id, f = input.dataset.f, c = cById(id);
|
|
if (!c) return;
|
|
const v = input.value;
|
|
if (f === 'jobFull') {
|
|
const grp = JOB_DICT.find(g => (g.jobs || []).some(j => j.code === v));
|
|
const job = grp && grp.jobs.find(j => j.code === v);
|
|
if (grp && job) {
|
|
c._catCode = grp.code; c._jobCode = v;
|
|
c.jobTitleName = job.name; c.jobTitleCategory = grp.cat; c.jobTitleLevel = job.level;
|
|
}
|
|
} else if (f === 'isKeyContact') {
|
|
c[f] = Number(v);
|
|
} else {
|
|
c[f] = v;
|
|
}
|
|
C.dirtyRows[id] = true;
|
|
const tr = input.closest('tr');
|
|
tr.classList.add('dirty-row');
|
|
if (!tr.querySelector('.undo-link')) {
|
|
const td = tr.lastElementChild;
|
|
const s = document.createElement('span');
|
|
s.className = 'undo-link'; s.dataset.op = 'undo'; s.dataset.id = id; s.textContent = '撤销修改';
|
|
td.appendChild(s);
|
|
}
|
|
}
|
|
function undoRow(id) {
|
|
const snap = C.editSnapshot[id], c = cById(id);
|
|
if (snap && c) Object.assign(c, snap);
|
|
delete C.dirtyRows[id];
|
|
renderContactsList();
|
|
toast('已撤销该行修改');
|
|
}
|
|
async function saveListAll() {
|
|
const dirtyIds = Object.keys(C.dirtyRows);
|
|
if (!dirtyIds.length) { toast('无修改行'); return true; }
|
|
let firstErr = null;
|
|
for (const id of dirtyIds) {
|
|
const c = cById(id);
|
|
if (!c) continue;
|
|
let msg = null;
|
|
if (!c.name || !c.name.trim()) msg = '姓名必填';
|
|
else if (!(c._jobCode || jobCodeOfName(c.jobTitleName))) msg = '职务必填';
|
|
else if (c.phone && c.phone.indexOf('*') < 0 && !/^1\d{10}$/.test(c.phone)) msg = '电话须为 11 位手机号(脱敏回显=未修改可放行)';
|
|
if (msg && !firstErr) firstErr = { id: id, msg: msg };
|
|
}
|
|
if (firstErr) {
|
|
renderContactsList();
|
|
const tr = $('contactTbody').querySelector('tr[data-id="' + firstErr.id + '"]');
|
|
if (tr) { tr.classList.add('err-row'); tr.scrollIntoView({ block: 'center' }); }
|
|
const c = cById(firstErr.id);
|
|
toast('校验失败:' + (c ? c.name + ' — ' : '') + firstErr.msg + '(已定位首个错误行,不提交任何行)', 'err');
|
|
return false;
|
|
}
|
|
const form = new URLSearchParams();
|
|
dirtyIds.forEach((id, i) => {
|
|
const c = cById(id);
|
|
form.set('rows[' + i + '].id', c.id);
|
|
form.set('rows[' + i + '].name', c.name.trim());
|
|
form.set('rows[' + i + '].jobTitleCode', c._jobCode || jobCodeOfName(c.jobTitleName));
|
|
form.set('rows[' + i + '].phone', c.phone || '');
|
|
form.set('rows[' + i + '].source', c.source || '');
|
|
form.set('rows[' + i + '].isKeyContact', Number(c.isKeyContact) ? '1' : '0');
|
|
});
|
|
try {
|
|
const ret = await api('/api/customer/contact/batchEdit', { method: 'POST', body: form.toString() });
|
|
if (ret && ret.failedRows && ret.failedRows.length) {
|
|
const first = ret.failedRows[0];
|
|
const c = cById(dirtyIds[first.rowIndex]);
|
|
toast('保存失败(原子批零落库):' + (c ? c.name + ' — ' : '') + (first.reason || ''), 'err');
|
|
return false;
|
|
}
|
|
await Promise.all([loadContacts(), loadGraph()]);
|
|
C.dirtyRows = {};
|
|
addLog('数据', '页内编辑保存全部:共 ' + dirtyIds.length + ' 行(batchEdit 原子批)');
|
|
toast('保存全部成功', 'ok');
|
|
layoutGraph(); renderContactsAll();
|
|
return true;
|
|
} catch (err) {
|
|
toast('保存失败(' + err.code + '):' + err.message, 'err');
|
|
return false;
|
|
}
|
|
}
|
|
/* ---- 快速添加(quickAdd 多行/查重/确认知悉;部分成功:合法行落库非法行明细) ---- */
|
|
function emptyQaRow() { return { name: '', cat: '', job: '', phone: '', key: false, source: '', ack: false }; }
|
|
async function openQa() {
|
|
await ensureDicts();
|
|
qaRows = [emptyQaRow(), emptyQaRow()];
|
|
$('qaCustName').textContent = custName();
|
|
renderQa();
|
|
$('qaOverlay').classList.add('show');
|
|
}
|
|
function qaClose() { $('qaOverlay').classList.remove('show'); }
|
|
function qaAddRow() { qaRows.push(emptyQaRow()); renderQa(); }
|
|
function renderQa() {
|
|
$('qaTbody').innerHTML = qaRows.map((r, i) => {
|
|
const catOpts = '<option value="">--选择类别--</option>' + JOB_DICT.map(g => '<option value="' + esc(g.code) + '"' + (r.cat === g.code ? ' selected' : '') + '>' + esc(g.cat) + '</option>').join('');
|
|
const grp = JOB_DICT.find(g => g.code === r.cat);
|
|
const jobOpts = grp
|
|
? grp.jobs.map(j => '<option value="' + esc(j.code) + '"' + (r.job === j.code ? ' selected' : '') + '>' + esc(j.name) + '</option>').join('')
|
|
: '<option value="">--先选类别--</option>';
|
|
return '<tr data-qa="' + i + '">' +
|
|
'<td>' + (i + 1) + '</td>' +
|
|
'<td><input type="text" data-qf="name" value="' + esc(r.name) + '"></td>' +
|
|
'<td><select data-qf="cat">' + catOpts + '</select></td>' +
|
|
'<td><select data-qf="job">' + jobOpts + '</select></td>' +
|
|
'<td><input type="text" data-qf="phone" value="' + esc(r.phone) + '" placeholder="选填,11 位手机号"></td>' +
|
|
'<td><input type="checkbox" data-qf="key"' + (r.key ? ' checked' : '') + '></td>' +
|
|
'<td><select data-qf="source"><option value="">--</option>' + SOURCE_DICT.map(s => '<option value="' + esc(s.code) + '"' + (r.source === s.code ? ' selected' : '') + '>' + esc(s.name) + '</option>').join('') + '</select></td>' +
|
|
'<td><input type="checkbox" data-qf="ack"' + (r.ack ? ' checked' : '') + '><span class="qa-hint" data-hint="' + i + '"></span></td>' +
|
|
'</tr>';
|
|
}).join('');
|
|
}
|
|
function qaRowIsEmpty(r) { return !r.name && !r.cat && !r.job && !r.phone && !r.source && !r.key; }
|
|
function qaDupReason(r, batch) {
|
|
if (r.phone) {
|
|
if (C.list.some(c => c.phone && maskPhone(r.phone) === c.phone)) return '疑似重复:手机号已存在(' + maskPhone(r.phone) + ')';
|
|
if (batch && batch.some(o => o !== r && o.phone && o.phone === r.phone)) return '疑似重复:与本批另一行手机号重复';
|
|
} else if (r.name && r.job) {
|
|
const grp = JOB_DICT.find(g => g.code === r.cat);
|
|
const jobName = grp && (grp.jobs.find(j => j.code === r.job) || {}).name;
|
|
if (jobName && C.list.some(c => c.name === r.name.trim() && c.jobTitleName === jobName)) return '疑似重复:同客户已有「' + r.name.trim() + ' + ' + jobName + '」';
|
|
}
|
|
return null;
|
|
}
|
|
function qaTableChange(e) {
|
|
const tr = e.target.closest('tr');
|
|
if (!tr || tr.dataset.qa == null) return;
|
|
const r = qaRows[Number(tr.dataset.qa)];
|
|
if (!r || !e.target.dataset.qf) return;
|
|
const f = e.target.dataset.qf;
|
|
if (e.target.type === 'checkbox') r[f] = e.target.checked;
|
|
else r[f] = e.target.value;
|
|
if (f === 'cat') { r.job = ''; renderQa(); } // 类别 → 职务二级联动
|
|
}
|
|
function qaTableInput(e) {
|
|
const tr = e.target.closest('tr');
|
|
if (!tr || tr.dataset.qa == null) return;
|
|
const r = qaRows[Number(tr.dataset.qa)];
|
|
if (r && e.target.dataset.qf && e.target.type !== 'checkbox') r[e.target.dataset.qf] = e.target.value;
|
|
}
|
|
async function qaSubmit() {
|
|
const rows = qaRows.filter(r => !qaRowIsEmpty(r));
|
|
if (!rows.length) { toast('请至少填写一行内容(空行不提交)', 'err'); return; }
|
|
const errs = [];
|
|
let firstErrIdx = -1;
|
|
rows.forEach(r => {
|
|
const idx = qaRows.indexOf(r);
|
|
let msg = null;
|
|
if (!r.name.trim()) msg = '姓名必填';
|
|
else if (!r.cat || !r.job) msg = '职务必填(先选类别再选职务)';
|
|
else if (r.phone && !/^1\d{10}$/.test(r.phone)) msg = '电话须为 11 位手机号';
|
|
if (!msg) { const dup = qaDupReason(r, rows); if (dup && !r.ack) msg = dup + ',请勾选「确认知悉」'; }
|
|
if (msg) { errs.push({ idx: idx, msg: msg }); if (firstErrIdx < 0) firstErrIdx = idx; }
|
|
});
|
|
$('qaTbody').querySelectorAll('tr').forEach(tr => { tr.classList.remove('qa-err', 'qa-dup'); const h = tr.querySelector('.qa-hint'); if (h) h.textContent = ''; });
|
|
if (errs.length) {
|
|
errs.forEach(er => {
|
|
const tr = $('qaTbody').querySelector('tr[data-qa="' + er.idx + '"]');
|
|
if (tr) { tr.classList.add(er.msg.indexOf('疑似重复') >= 0 ? 'qa-dup' : 'qa-err'); const h = tr.querySelector('.qa-hint'); if (h) h.textContent = er.msg; }
|
|
});
|
|
const tr = $('qaTbody').querySelector('tr[data-qa="' + firstErrIdx + '"]');
|
|
if (tr) tr.scrollIntoView({ block: 'center' });
|
|
toast('提交失败:' + errs.length + ' 行有误(已保留全部内容并定位首错行)', 'err');
|
|
return;
|
|
}
|
|
const form = new URLSearchParams();
|
|
form.set('customerId', D.id);
|
|
rows.forEach((r, i) => {
|
|
form.set('rows[' + i + '].name', r.name.trim());
|
|
form.set('rows[' + i + '].jobTitleCode', r.job);
|
|
form.set('rows[' + i + '].phone', r.phone.trim());
|
|
form.set('rows[' + i + '].source', r.source);
|
|
form.set('rows[' + i + '].isKeyContact', r.key ? '1' : '0');
|
|
});
|
|
try {
|
|
const ret = await api('/api/customer/contact/quickAdd', { method: 'POST', body: form.toString() });
|
|
const failed = (ret && ret.failedRows) || [];
|
|
if (failed.length) {
|
|
failed.forEach(f => {
|
|
const idx = qaRows.indexOf(rows[f.rowIndex]);
|
|
const tr = $('qaTbody').querySelector('tr[data-qa="' + idx + '"]');
|
|
if (tr) { tr.classList.add('qa-err'); const h = tr.querySelector('.qa-hint'); if (h) h.textContent = f.reason; }
|
|
});
|
|
toast(((ret && ret.successCount) || 0) + ' 行成功,' + failed.length + ' 行失败(失败行保留待改,可重提)', 'err');
|
|
return;
|
|
}
|
|
qaClose();
|
|
await Promise.all([loadContacts(), loadGraph()]);
|
|
layoutGraph();
|
|
addLog('数据', '快速添加:' + ((ret && ret.successCount) || rows.length) + ' 位联系人');
|
|
toast('成功添加 ' + ((ret && ret.successCount) || rows.length) + ' 位联系人,列表与图谱已刷新', 'ok');
|
|
renderContactsAll();
|
|
} catch (err) {
|
|
toast('提交失败(' + err.code + '):' + err.message, 'err');
|
|
}
|
|
}
|
|
/* ---- 联系人导入三段式(upload→confirm→result/failures 轮询;id 加 cimp 前缀避让客户导入 tab) ---- */
|
|
let cimpTaskId = null, cimpPollTimer = null;
|
|
function openCimp() {
|
|
cimpTaskId = null;
|
|
clearInterval(cimpPollTimer);
|
|
$('cimpStep1').style.display = ''; $('cimpStep2').style.display = 'none'; $('cimpStep3').style.display = 'none';
|
|
$('cimpFile').value = '';
|
|
$('cimpCustName').textContent = custName();
|
|
$('cimpOverlay').classList.add('show');
|
|
}
|
|
function cimpClose() { $('cimpOverlay').classList.remove('show'); clearInterval(cimpPollTimer); }
|
|
async function cimpTemplateDownload() {
|
|
try {
|
|
const blob = await apiDownload('/api/customer/contact/import/template', 'GET');
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = '联系人导入模板-V1.xlsx';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
} catch (err) { toast('模板下载失败(' + err.code + '):' + err.message, 'err'); }
|
|
}
|
|
function cimpReupload() { $('cimpStep1').style.display = ''; $('cimpStep2').style.display = 'none'; }
|
|
async function cimpUpload() {
|
|
const file = $('cimpFile').files[0];
|
|
if (!file) { toast('请先选择 .xlsx 文件', 'err'); return; }
|
|
const fd = new FormData();
|
|
fd.append('customerId', D.id);
|
|
fd.append('file', file);
|
|
fd.append('importMode', $('cimpMode').value);
|
|
fd.append('duplicateStrategy', $('cimpStrategy').value);
|
|
$('cimpUploadBtn').disabled = true;
|
|
try {
|
|
const p = await api('/api/customer/contact/import/upload', { method: 'POST', body: fd });
|
|
cimpTaskId = p.taskId;
|
|
$('cimpPreviewHead').textContent = '预检完成(任务 ' + p.taskId + ' 待确认):共 ' + p.totalCount
|
|
+ ' 行,预计新增 ' + p.insertCount + '、更新 ' + p.updateCount + '、失败 ' + p.failCount + '、疑似重复 ' + p.suspectCount;
|
|
$('cimpPreviewTbody').innerHTML = (p.rows || []).map(r =>
|
|
'<tr><td>' + r.rowNum + '</td><td>' + esc(r.identity || '') + '</td><td>' + esc(r.verdict || '') + '</td><td>' + esc(r.reason || '--') + '</td></tr>').join('')
|
|
|| '<tr><td colspan="4" class="empty">无明细(全部可入库)</td></tr>';
|
|
$('cimpStep1').style.display = 'none'; $('cimpStep2').style.display = '';
|
|
} catch (err) {
|
|
toast('预检失败(' + err.code + '):' + err.message, 'err'); // 67023 守门/模板版本/行数超限
|
|
} finally {
|
|
$('cimpUploadBtn').disabled = false;
|
|
}
|
|
}
|
|
async function cimpConfirmTask() {
|
|
if (!cimpTaskId) return;
|
|
try {
|
|
await api('/api/customer/contact/import/confirm?taskId=' + encodeURIComponent(cimpTaskId), { method: 'POST' });
|
|
$('cimpStep2').style.display = 'none'; $('cimpStep3').style.display = '';
|
|
$('cimpResultHead').textContent = '导入执行中…';
|
|
$('cimpFailTbody').innerHTML = '';
|
|
pollCimpResult();
|
|
} catch (err) {
|
|
toast('确认失败(' + err.code + '):' + err.message, 'err'); // 67024 重复确认/状态不允许
|
|
}
|
|
}
|
|
function pollCimpResult() {
|
|
clearInterval(cimpPollTimer);
|
|
const tick = async () => {
|
|
try {
|
|
const r = await api('/api/customer/contact/import/result?taskId=' + encodeURIComponent(cimpTaskId));
|
|
if (r.status === 1) { $('cimpResultHead').textContent = '导入执行中…(总 ' + r.totalCount + ' 行)'; return; }
|
|
clearInterval(cimpPollTimer);
|
|
const statusTxt = r.status === 2 ? '已完成' : (r.status === 3 ? '已失败:' + (r.failReason || '--') : '状态 ' + r.status);
|
|
$('cimpResultHead').textContent = statusTxt + ':新增 ' + r.insertCount + '、更新 ' + r.updateCount
|
|
+ '、未变化 ' + r.unchangedCount + '、失败 ' + r.failCount + '、疑似重复 ' + r.suspectCount;
|
|
const fails = await api('/api/customer/contact/import/failures?taskId=' + encodeURIComponent(cimpTaskId)).catch(() => []);
|
|
$('cimpFailTbody').innerHTML = (fails || []).map(f =>
|
|
'<tr><td>' + f.rowNum + '</td><td>' + esc(f.name || '') + '</td><td>' + esc(f.phone || '--') + '</td><td>' + esc(f.failReason || '') + '</td></tr>').join('')
|
|
|| '<tr><td colspan="4" class="empty">无失败/疑似明细</td></tr>';
|
|
if (D.sub === 'contacts') {
|
|
await Promise.all([loadContacts(), loadGraph()]);
|
|
layoutGraph(); renderContactsAll();
|
|
}
|
|
} catch (err) {
|
|
clearInterval(cimpPollTimer);
|
|
toast('结果查询失败(' + err.code + '):' + err.message, 'err');
|
|
}
|
|
};
|
|
tick();
|
|
cimpPollTimer = setInterval(tick, 1000);
|
|
}
|
|
function cimpDone() { $('cimpOverlay').classList.remove('show'); clearInterval(cimpPollTimer); }
|
|
/* ---- 批量导出(POST /export 全量导出该客户 xlsx;电话列脱敏口径) ---- */
|
|
async function exportContacts() {
|
|
try {
|
|
const blob = await apiDownload('/api/customer/contact/export?customerId=' + D.id, 'POST');
|
|
const a = document.createElement('a');
|
|
a.href = URL.createObjectURL(blob);
|
|
a.download = '联系人导出.xlsx';
|
|
a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
addLog('数据', opName() + ' 导出联系人(xlsx 全量,电话恒脱敏)');
|
|
toast('导出已提交(全量,电话恒脱敏)', 'ok');
|
|
} catch (err) {
|
|
toast('导出失败(' + err.code + '):' + err.message, 'err');
|
|
}
|
|
}
|
|
/* ---- 一次性事件绑定(contactsShell 常驻 DOM → init 时绑一次;委托 contactTbody/qaTbody) ---- */
|
|
function bindContactsUI() {
|
|
wrap.addEventListener('mousedown', graphMouseDown);
|
|
document.addEventListener('mousemove', graphMouseMove);
|
|
document.addEventListener('mouseup', graphMouseUp);
|
|
wrap.addEventListener('wheel', graphWheel, { passive: false });
|
|
edgeSvg.addEventListener('click', edgeDeleteClick);
|
|
canvasInner.addEventListener('click', cardPhoneClick);
|
|
canvasInner.addEventListener('mouseover', cardBubbleOver);
|
|
canvasInner.addEventListener('mouseout', e => { if (e.target.closest('.gcard')) hideBubble(); });
|
|
$('contactTbody').addEventListener('click', function (e) {
|
|
const op = e.target.closest('[data-op]');
|
|
if (!op) return;
|
|
if (op.dataset.op === 'reveal') { revealOne(op.dataset.id).then(renderContactsAll); return; }
|
|
if (op.dataset.op === 'undo') { undoRow(op.dataset.id); return; }
|
|
if (op.dataset.op === 'edit') { dlgContact(op.dataset.id); return; } // 旧 contact/edit 弹窗保留演示
|
|
if (op.dataset.op === 'check') { dlgCheckPhone(op.dataset.phone); return; }
|
|
if (op.dataset.op === 'del') { delContact(op.dataset.id); return; }
|
|
});
|
|
$('contactTbody').addEventListener('change', function (e) {
|
|
if (e.target.classList.contains('row-chk')) {
|
|
const k = e.target.dataset.id;
|
|
if (e.target.checked) C.checked[k] = true; else delete C.checked[k];
|
|
renderContactsList();
|
|
return;
|
|
}
|
|
if (e.target.dataset.f && C.editing) applyCellEdit(e.target);
|
|
});
|
|
$('contactTbody').addEventListener('input', function (e) {
|
|
if (e.target.dataset.f && C.editing) applyCellEdit(e.target, true);
|
|
});
|
|
$('qaTbody').addEventListener('change', qaTableChange);
|
|
$('qaTbody').addEventListener('input', qaTableInput);
|
|
}
|
|
/* ================= 初始化 ================= */
|
|
(function init() {
|
|
$('userSel').innerHTML = USERS.map(u => '<option value="' + u.id + '">' + esc(u.name) + '</option>').join('');
|
|
$('tplLink').href = API_BASE + '/api/customer/import/template';
|
|
$('apiBox').textContent = 'API: ' + (API_BASE || '同源部署') + (localStorage.getItem('e2c_api') ? '(已记忆,?api=reset 恢复本地)' : '');
|
|
paintWho();
|
|
bindContactsUI();
|
|
if (TOKEN()) { loadList(); loadSummary(); }
|
|
})();
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|