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.
830 lines
52 KiB
830 lines
52 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; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>客户模块 E2E Demo</h1>
|
|
<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(1)">客户交割</button>
|
|
<button data-tab="settings" onclick="switchTab('settings');loadReminder()">设置</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>
|
|
</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="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="members" onclick="switchSub('members')">团队成员</button>
|
|
<button data-s="oplog" onclick="switchSub('oplog')">操作日志</button>
|
|
</div>
|
|
<div id="subBox"></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 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>
|
|
<input type="file" id="impFile" accept=".xlsx">
|
|
<button onclick="impUpload()">上传预检</button>
|
|
</div>
|
|
<div class="hint">上传后生成 DRAFT 任务并返回行级预检计数;确认后异步执行,任务列表可轮询结果/失败明细。67013=预检失败(mode/表头/空文件/行数),67014=状态机不允许(重复 confirm)。</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">PUT 为全量覆盖:开关字段缺省按 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>查重试玩(消费侧 · 单例未落地=出厂默认:名称相似 80 / 信用代码拦 / 电话软提示)</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>
|
|
|
|
<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 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(path, opt);
|
|
let j = null;
|
|
try { j = await r.json(); } catch (e) {}
|
|
if (r.status === 401) { toast('未登录或 token 失效,请先在页头登录', 'err'); throw new Error('401 未授权'); }
|
|
if (j && j.code !== 0 && j.code !== undefined) {
|
|
if (j.code === 40401 || r.status === 404) toast('接口不存在(404):' + path, 'err');
|
|
const e = new Error(j.message || ('HTTP ' + r.status)); e.code = j.code; e.data = j.data; throw e;
|
|
}
|
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
return j ? j.data : null;
|
|
}
|
|
|
|
/* ================= 登录 ================= */
|
|
async function loginByUid() {
|
|
const uid = ($('uidInput').value || '').trim() || $('userSel').value;
|
|
if (!uid) return toast('请输入 userId', 'err');
|
|
try {
|
|
const r = await fetch('/api/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/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/' + S.ws + '/page', {
|
|
method: 'POST',
|
|
body: qs({ 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="11" 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 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/' + S.ws + '/board/summary', { method: 'POST', body: qs({ 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; }
|
|
}
|
|
|
|
/* ================= 弹窗框架 ================= */
|
|
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'), creditCode: 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', { 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, '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/' + 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)}(PUT 带回做 CAS)</div>
|
|
<div id="eOut"></div>`,
|
|
[{ text: '保存', ok: true, fn: doEdit }]);
|
|
window._editId = id; window._editVer = d.version;
|
|
} catch (e) { toast(e.message, 'err'); }
|
|
}
|
|
async function doEdit() {
|
|
const id = window._editId;
|
|
try {
|
|
await api('/api/customer/' + id, { method: 'PUT', 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,
|
|
}) });
|
|
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');
|
|
try { await act('/api/customer/' + op + '?id=' + id); } 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');
|
|
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); }
|
|
|
|
/* ================= 客户详情 ================= */
|
|
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/' + D.id + '/detail-head');
|
|
$('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="dlgContact()">+联系人</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');
|
|
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/' + D.id);
|
|
box.innerHTML = '<div class="grid">' + grid(d) + '</div>';
|
|
} else if (s === 'contacts') {
|
|
const d = await api('/api/customer/' + D.id + '/contacts');
|
|
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
|
|
window._contacts = rows;
|
|
box.innerHTML = contactsHtml(rows);
|
|
} else if (s === 'follow') {
|
|
const d = await api('/api/customer/' + D.id + '/follow/page?current=1&size=50');
|
|
box.innerHTML = followHtml((d && d.content) || []);
|
|
} else if (s === 'opps') {
|
|
const d = await api('/api/customer/' + D.id + '/opportunities');
|
|
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 === 'members') {
|
|
const d = await api('/api/customer/' + D.id + '/members');
|
|
const rows = Array.isArray(d) ? d : ((d && d.content) || []);
|
|
box.innerHTML = membersHtml(rows);
|
|
} else if (s === 'oplog') {
|
|
const d = await api('/api/customer/' + D.id + '/oplog/page?current=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>';
|
|
}
|
|
/* ---- 联系人 ---- */
|
|
function contactsHtml(rows) {
|
|
const t = tableHtml(['姓名', '职务', '电话', '主要', '备注', '操作'], rows.map(c =>
|
|
[esc(c.contactName || c.name || '--'), esc(c.position || c.title || '--'), esc(c.phone || c.mobile || '--'),
|
|
(c.isPrimary === 1 || c.isPrimary === true) ? '是' : '', esc(c.remark || '--'),
|
|
'<button onclick="dlgContact(\'' + c.id + '\')">编辑</button> <button onclick="delContact(\'' + c.id + '\')">删除</button> <button onclick="dlgCheckPhone(\'' + esc(c.phone || c.mobile || '') + '\')">查重</button>']));
|
|
return '<div class="toolbar"><button onclick="dlgContact()">+ 新建联系人</button></div>' + t;
|
|
}
|
|
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.contactName || c.name || '')}"></label>
|
|
<label><b>职务</b><input id="ctPos" value="${esc(c.position || c.title || '')}"></label>
|
|
<label><b>电话*</b><input id="ctPhone" value="${esc(c.phone || c.mobile || '')}"></label>
|
|
<label><b>主要联系人</b><select id="ctPrimary"><option value="0" ${c.isPrimary ? '' : 'selected'}>否</option><option value="1" ${c.isPrimary ? 'selected' : ''}>是</option></select></label>
|
|
<label><b>备注</b><input id="ctRemark" value="${esc(c.remark || '')}"></label>
|
|
<div id="ctOut"></div>`,
|
|
[{ text: '保存', ok: true, fn: async () => {
|
|
const fields = { contactName: formVal('ctName'), position: formVal('ctPos'), phone: formVal('ctPhone'), isPrimary: formVal('ctPrimary'), remark: formVal('ctRemark') };
|
|
const bodyObj = cid ? Object.assign({ id: cid, customerId: D.id }, fields) : Object.assign({ customerId: D.id }, fields);
|
|
try { await api('/api/customer/contact', { method: cid ? 'PUT' : 'POST', body: qs(bodyObj) }); toast('联系人已保存', 'ok'); dlgClose(); switchSub('contacts'); }
|
|
catch (e) { $('ctOut').innerHTML = errHtml(e); }
|
|
} }]);
|
|
}
|
|
async function delContact(cid) {
|
|
if (!confirm('确认删除该联系人?')) return;
|
|
try { await api('/api/customer/contact?id=' + cid, { method: 'DELETE' }); toast('已删除', 'ok'); switchSub('contacts'); }
|
|
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="PHONE">电话</option><option value="VISIT">拜访</option><option value="WECHAT">微信</option><option value="OTHER">其他</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/' + D.id + '/follow', { 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 t = tableHtml(['成员', '角色', '操作'], rows.map(m =>
|
|
[esc(m.userName || m.memberUserName || m.userId || '--'),
|
|
(m.roleType === 'OWNER' || m.roleType === 1 || m.isOwner === true) ? badge('负责人', 'b2') : badge('协同人', 'b1'),
|
|
(m.roleType === 'OWNER' || m.roleType === 1 || m.isOwner === true) ? '--' : '<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/' + D.id + '/members', { 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/' + D.id + '/members/' + uid, { method: 'DELETE' }); 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('');
|
|
}
|
|
|
|
/* ================= 客户导入 ================= */
|
|
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);
|
|
const d = await api('/api/customer/import/upload', { method: 'POST', body: fd });
|
|
toast('预检完成 taskId=' + d.taskId + ' 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/' + taskId + '/confirm', { 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/' + 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/' + taskId + '/failures/page?current=1&size=50');
|
|
const rows = (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/' + 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/' + tid + '/assign?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/' + tid + '/assignable');
|
|
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', {
|
|
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
masterEnabled: +$('rMaster').value, firstTriggerEnabled: +$('rFirstEn').value, firstTriggerDays: +$('rFirstDays').value,
|
|
secondIntervalEnabled: +$('rSecondEn').value, secondIntervalDays: +$('rSecondDays').value,
|
|
})
|
|
});
|
|
toast('已保存(PUT 全量覆盖,开关缺省按禁用;天数非正整数 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); }
|
|
}
|
|
|
|
/* ================= 初始化 ================= */
|
|
(function init() {
|
|
$('userSel').innerHTML = USERS.map(u => '<option value="' + u.id + '">' + esc(u.name) + '</option>').join('');
|
|
paintWho();
|
|
if (TOKEN()) { loadList(); loadSummary(); }
|
|
})();
|
|
|
|
</script>
|
|
</body>
|
|
</html>
|
|
|