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.
1237 lines
79 KiB
1237 lines
79 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; }
|
|
.stagebar { display: flex; gap: 4px; align-items: center; margin: 8px 0; flex-wrap: wrap; }
|
|
.stagebar .node { padding: 3px 12px; border-radius: 12px; border: 1px solid #dcdfe6; font-size: 12px; }
|
|
.stagebar .node.done { background: #e8f7e8; border-color: #b3e0b3; }
|
|
.stagebar .node.cur { background: #409eff; color: #fff; border-color: #409eff; }
|
|
.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: 640px; }
|
|
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; }
|
|
.lanes { display: flex; gap: 10px; overflow-x: auto; padding: 8px 0 4px; align-items: flex-start; }
|
|
.lane { min-width: 224px; width: 224px; flex-shrink: 0; background: #f7f8fa; border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px; }
|
|
.laneHead { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
|
|
.laneBody { display: flex; flex-direction: column; gap: 6px; min-height: 30px; }
|
|
.laneCard { background: #fff; border: 1px solid #e4e7ed; border-radius: 4px; padding: 6px 8px; cursor: pointer; font-size: 12px; display: flex; flex-direction: column; gap: 2px; }
|
|
.laneCard:hover { border-color: #409eff; background: #f0f7ff; }
|
|
.laneFoot { text-align: center; margin-top: 6px; }
|
|
.laneFoot button { padding: 2px 10px; border: 1px solid #dcdfe6; background: #fff; border-radius: 4px; cursor: pointer; font-size: 12px; }
|
|
fieldset { border: 1px solid #e4e7ed; border-radius: 6px; padding: 8px 10px 10px; display: flex; flex-direction: column; gap: 10px; }
|
|
fieldset legend { padding: 0 6px; font-size: 12px; color: #909399; }
|
|
</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="rule" onclick="switchTab('rule');loadRules()">规则配置</button>
|
|
</nav>
|
|
<main>
|
|
<section id="tab-list">
|
|
<div class="panel">
|
|
<div class="viewbar" id="viewBar"></div>
|
|
<div class="viewbar">
|
|
<input id="fKeyword" placeholder="名称关键词" style="width:160px">
|
|
<select id="fStatus"><option value="">全部状态</option>
|
|
<option value="1">待领取</option><option value="2">推进中</option>
|
|
<option value="3">暂缓中</option><option value="4">已关闭</option><option value="5">已转项目</option></select>
|
|
<button onclick="S.page=1;S.total=0;loadList(1)">查询</button>
|
|
<span class="hint" id="listHint"></span>
|
|
<span class="sp"></span>
|
|
<span class="hint">自定义视图(个人,scopeKey=opportunity):</span>
|
|
<select id="cviewSel" onchange="onCview()"><option value="">—内置视图—</option></select>
|
|
<button onclick="dlgSaveView()">存为视图</button>
|
|
</div>
|
|
<table><thead><tr>
|
|
<th>商机名称</th><th>状态</th><th>当前阶段</th><th>负责人</th><th>所属部门</th>
|
|
<th>行业</th><th>来源</th><th>最近跟进</th><th>操作</th>
|
|
</tr></thead><tbody id="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>看板汇总(当前视图)<span class="mark" title="D-15:PUBLIC_POOL 视图 summary 未按视图状态过滤(7 vs 实际 2)">D-15</span></b><div id="boardBox" class="stagebar"></div></div>
|
|
<div class="panel"><b>看板泳道(当前视图 · 按展示名取卡,offset+limit 滚动加载)</b><div id="laneBox" class="lanes"><span class="hint">随视图加载,每列先拉 5 条,点「更多」续拉</span></div></div>
|
|
</section>
|
|
|
|
<section id="tab-detail" style="display:none">
|
|
<div id="detailBox"><div class="panel empty">请先在「商机列表」点行进入详情</div></div>
|
|
</section>
|
|
|
|
<section id="tab-rule" style="display:none">
|
|
<div class="panel">
|
|
<div class="viewbar" id="ruleBar"></div>
|
|
<div id="ruleListBox"></div>
|
|
</div>
|
|
<div id="ruleDetailBox"></div>
|
|
</section>
|
|
</main>
|
|
<div id="toast"></div>
|
|
|
|
<dialog id="dlg"><form id="dlgForm" onsubmit="return false"></form></dialog>
|
|
|
|
<script>
|
|
/* ================= 常量与状态 ================= */
|
|
const BASE = 'https://ai.itc.vip/crm-api'; // 20260901 验收切测试环境(原 http://localhost:8080,本地起服见 boot-verify.md)
|
|
const USERS = [
|
|
{ uid: '739564171091247104', name: '罗伟健(管理员,全量可见)' },
|
|
{ uid: '744842565802524672', name: '赖永利(A·特战团队)' },
|
|
{ uid: '744842566742048768', name: '肖琴(B·冠军团队)' },
|
|
{ uid: '744842318024015872', name: '曾偲青(C·职员,跨中心)' },
|
|
];
|
|
const VIEWS = [
|
|
['RECENT', '最近访问'], ['MINE', '我负责的'], ['PARTICIPATED', '我参与的'],
|
|
['FOLLOWED', '我关注的'], ['MANAGE', '管理(全量)'], ['PUBLIC_POOL', '公海'],
|
|
];
|
|
const STATUS = { 1: ['待领取', 'b1'], 2: ['推进中', 'b2'], 3: ['暂缓中', 'b3'], 4: ['已关闭', 'b4'], 5: ['已转项目', 'b5'] };
|
|
const FAMILIES = [
|
|
['opp-stage-template', '商机阶段设置'], ['opp-scheme-template', '方案卡模板'], ['opp-pool-rule', '公海与提醒'],
|
|
];
|
|
const S = { token: null, user: USERS[0], tab: 'list', view: 'MINE', page: 1, total: 0,
|
|
dict: {}, oppId: null, subTab: 'follow', family: 'opp-pool-rule', cviews: [], detail: null };
|
|
|
|
/* ================= 基础设施 ================= */
|
|
function toast(msg, kind = 'info') {
|
|
const d = document.createElement('div');
|
|
d.className = 'toast-item toast-' + kind; d.textContent = msg;
|
|
document.getElementById('toast').appendChild(d);
|
|
setTimeout(() => d.remove(), kind === 'err' ? 6000 : 2500);
|
|
}
|
|
function esc(s) { return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
|
|
|
|
async function api(method, path, { form, json, params } = {}) {
|
|
const qs = params ? '?' + new URLSearchParams(params) : '';
|
|
const opt = { method, headers: { Authorization: 'Bearer ' + S.token } };
|
|
if (form) { opt.headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
|
opt.body = new URLSearchParams(form); }
|
|
else if (json) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(json); }
|
|
const r = await fetch(BASE + path + qs, opt);
|
|
let body;
|
|
try { body = await r.json(); } catch { throw new Error(`HTTP ${r.status}(非 JSON): ${(await r.text()).slice(0, 200)}`); }
|
|
if (body.code !== 0) { const e = new Error(body.message || ('code=' + body.code)); e.code = body.code; e.raw = JSON.stringify(body); throw e; }
|
|
return body.data;
|
|
}
|
|
async function tryApi(step, fn, silent) {
|
|
try { return await fn(); }
|
|
catch (e) { if (!silent) toast(`[${step}] ${e.message}`, 'err'); console.warn(step, e.raw || e); return null; }
|
|
}
|
|
|
|
/* okApi:无返回值写端点(data=null)专用——「不抛错即成功」,修复 demo-B2 */
|
|
async function okApi(step, fn) {
|
|
try { await fn(); return true; }
|
|
catch (e) { toast(`[${step}] ${e.message}`, 'err'); console.warn(step, e.raw || e); return false; }
|
|
}
|
|
|
|
/* ================= 登录选人 ================= */
|
|
function loginByUid() {
|
|
const uid = document.getElementById('uidInput').value.trim();
|
|
if (!uid) { toast('请先输入 userId(测试环境库的真实雪花 id,下拉里的开发库 uid 不适用)', 'err'); return; }
|
|
S.user = { uid: uid, name: 'uid直登' };
|
|
login();
|
|
}
|
|
function switchUser(idx) {
|
|
S.user = USERS[idx];
|
|
localStorage.setItem('demoUser', idx);
|
|
login();
|
|
}
|
|
async function login() {
|
|
document.getElementById('whoBox').textContent = '签发 token 中…';
|
|
const r = await fetch(`${BASE}/api/auth/debug/token?userId=${S.user.uid}`);
|
|
const body = await r.json();
|
|
if (body.code !== 0) { document.getElementById('whoBox').textContent = 'token 签发失败'; toast('token: ' + body.message, 'err'); return; }
|
|
S.token = body.data; S.page = 1; S.view = 'MINE'; S.oppId = null;
|
|
let me = await tryApi('me', () => api('GET', '/api/auth/me'), true);
|
|
document.getElementById('whoBox').textContent = `已登录:${me ? (me.realName || me.username || S.user.name) : S.user.name}(uid=${S.user.uid.slice(-6)})`;
|
|
loadDicts(); loadCviews(); switchTab('list'); renderViewBar(); loadList(1); loadBoard();
|
|
}
|
|
|
|
/* ================= 字典 ================= */
|
|
const DICT_GROUPS = ['opp_source', 'pause_reason', 'close_reason', 'pool_reason', 'bid_form', 'locality_type', 'customer_role', 'customer_type', 'follow_way', 'survey_seq', 'apply_way', 'result_tag', 'industry', 'opp_stage', 'attachment_type'];
|
|
async function loadDicts() {
|
|
for (const g of DICT_GROUPS) {
|
|
const items = await tryApi('dict:' + g, () => api('GET', '/api/dict/item/enabled-list', { params: { groupCode: g } }), true);
|
|
S.dict[g] = items || [];
|
|
}
|
|
}
|
|
function dictName(g, code) {
|
|
const it = (S.dict[g] || []).find(i => i.code === code);
|
|
return it ? it.name : (code || '—');
|
|
}
|
|
function dictOpts(g, sel) {
|
|
return (S.dict[g] || []).map(i => `<option value="${esc(i.code)}" ${i.code === sel ? 'selected' : ''}>${esc(i.name)}</option>`).join('');
|
|
}
|
|
|
|
/* ================= Tab 切换 ================= */
|
|
function switchTab(t) {
|
|
S.tab = t;
|
|
document.querySelectorAll('nav.tabs button').forEach(b => b.classList.toggle('on', b.dataset.tab === t));
|
|
for (const k of ['list', 'detail', 'rule'])
|
|
document.getElementById('tab-' + k).style.display = k === t ? '' : 'none';
|
|
}
|
|
function openDetailTab() {
|
|
switchTab('detail');
|
|
if (S.oppId) loadDetail(S.oppId);
|
|
}
|
|
|
|
/* ================= 列表 ================= */
|
|
function renderViewBar() {
|
|
document.getElementById('viewBar').innerHTML =
|
|
VIEWS.map(([k, n]) => `<button class="${S.view === k && !S.cviewId ? 'on' : ''}" onclick="pickView('${k}')">${n}</button>`).join('') +
|
|
(S.cviewId ? `<button class="on">${esc((S.cviews.find(v => v.viewId === S.cviewId) || {}).name || '')}(自定义)</button>` : '');
|
|
}
|
|
function pickView(v) { S.view = v; S.cviewId = null; S.page = 1; S.total = 0; renderViewBar(); loadList(1); loadBoard(); }
|
|
async function loadCviews() {
|
|
S.cviewId = null;
|
|
const list = await tryApi('view-list', () => api('GET', '/api/preference/view/list', { params: { scopeKey: 'opportunity' } }), true);
|
|
S.cviews = Array.isArray(list) ? list : [];
|
|
document.getElementById('cviewSel').innerHTML = '<option value="">—内置视图—</option>' +
|
|
S.cviews.map(v => `<option value="${esc(v.viewId)}">${esc(v.name)}${v.isDefault ? ' ⭐' : ''}</option>`).join('');
|
|
}
|
|
function onCview() { S.cviewId = document.getElementById('cviewSel').value || null; S.page = 1; S.total = 0; renderViewBar(); loadList(1); }
|
|
|
|
async function loadList(delta) {
|
|
if (!S.token) return;
|
|
if (delta === 1 && S.page * 10 < S.total) S.page++;
|
|
else if (delta === -1 && S.page > 1) S.page--;
|
|
else if (typeof delta === 'number' && delta >= 1) S.page = delta;
|
|
document.getElementById('listBody').innerHTML = '<tr><td colspan="9" class="loading">加载中…</td></tr>';
|
|
const form = { viewType: S.view, current: S.page, size: 10 };
|
|
if (S.cviewId) form.savedViewId = S.cviewId;
|
|
const kw = document.getElementById('fKeyword').value.trim();
|
|
const st = document.getElementById('fStatus').value;
|
|
if (kw) form.keyword = kw;
|
|
if (st) form.statusIn = st;
|
|
const d = await tryApi('page', () => api('POST', '/api/opportunity/page', { form }));
|
|
if (!d) { document.getElementById('listBody').innerHTML = '<tr><td colspan="9" class="empty">查询失败,见右上提示</td></tr>'; return; }
|
|
S.total = Number(d.total) || 0;
|
|
document.getElementById('listHint').textContent = `共 ${S.total} 条 · 第 ${S.page} 页`;
|
|
document.getElementById('pageInfo').textContent = `${S.page} / ${Math.max(1, Math.ceil(S.total / 10))}`;
|
|
const rows = d.content || [];
|
|
document.getElementById('listBody').innerHTML = rows.length ? rows.map(o => `
|
|
<tr class="row" onclick="loadDetail('${o.id}')">
|
|
<td>${esc(o.oppName || o.opportunityName)}</td>
|
|
<td>${badge(o.oppStatus)}</td>
|
|
<td>${esc(o.currentStageName || o.currentStageId || '—')}</td>
|
|
<td>${esc(o.ownerNameSnapshot || '—')}</td>
|
|
<td>${esc(o.ownerDeptName || '—')}</td>
|
|
<td>${esc(o.industryName || o.industryCode || '—')}</td>
|
|
<td>${esc(o.oppSourceName || o.oppSource || '—')}</td>
|
|
<td>${esc(o.lastValidFollowTime || '—')}</td>
|
|
<td><button onclick="event.stopPropagation();loadDetail('${o.id}')">详情</button></td>
|
|
</tr>`).join('') : '<tr><td colspan="9" class="empty">该视图暂无数据(试试切用户/视图)</td></tr>';
|
|
}
|
|
function badge(st) { const [n, c] = STATUS[st] || [st, 'b1']; return `<span class="badge ${c}">${n}</span>`; }
|
|
async function loadBoard() {
|
|
const box = document.getElementById('boardBox');
|
|
box.innerHTML = '<span class="loading">…</span>';
|
|
const d = await tryApi('board', () => api('POST', '/api/opportunity/board/stage-summary', { form: { viewType: S.view } }));
|
|
box.innerHTML = d && d.length ? d.map(x =>
|
|
`<span class="node">${esc(x.stageName || x.stageId)}:<b>${x.count}</b> 条 / ¥${x.totalAmount ?? 0}</span>`).join('') : '<span class="empty">无汇总数据</span>';
|
|
loadLanes(d || []);
|
|
}
|
|
|
|
/* ---------- 看板泳道(按展示名调 board/cards,offset+limit 滚动加载,返回 < limit 即到底) ---------- */
|
|
async function loadLanes(summary) {
|
|
const box = document.getElementById('laneBox');
|
|
S.lanes = (summary || []).map(x => ({ stageName: x.stageName || String(x.stageId), count: x.count || 0, offset: 0, limit: 5, done: false }));
|
|
if (!S.lanes.length) { box.innerHTML = '<span class="empty">无泳道数据</span>'; return; }
|
|
box.innerHTML = S.lanes.map((l, i) => `
|
|
<div class="lane"><div class="laneHead">${esc(l.stageName)} <b>${l.count}</b> 条</div>
|
|
<div class="laneBody" id="laneBody-${i}"><span class="loading">…</span></div>
|
|
<div class="laneFoot" id="laneFoot-${i}"></div></div>`).join('');
|
|
for (let i = 0; i < S.lanes.length; i++) await appendLane(i);
|
|
}
|
|
async function appendLane(i) {
|
|
const l = S.lanes[i];
|
|
const body = document.getElementById('laneBody-' + i), foot = document.getElementById('laneFoot-' + i);
|
|
const d = await tryApi('board/cards·' + l.stageName, () => api('POST', '/api/opportunity/board/cards',
|
|
{ form: { viewType: S.view, stageName: l.stageName, offset: l.offset, limit: l.limit } }), true) || [];
|
|
if (l.offset === 0) body.innerHTML = '';
|
|
body.insertAdjacentHTML('beforeend', d.map(c =>
|
|
`<div class="laneCard" onclick="loadDetail('${esc(String(c.id))}')">
|
|
<b>${esc(c.oppName || '')}</b>
|
|
<span>${esc(c.ownerNameSnapshot || c.ownerUserId || '—')} · ¥${c.projectAmount ?? 0} ${badge(c.oppStatus)}</span>
|
|
</div>`).join('') || (l.offset === 0 ? '<span class="empty">空列</span>' : ''));
|
|
l.offset += d.length;
|
|
if (d.length < l.limit) { l.done = true; foot.innerHTML = '<span class="hint">到底</span>'; }
|
|
else foot.innerHTML = `<button onclick="appendLane(${i})">更多(已 ${l.offset}/${l.count})</button>`;
|
|
}
|
|
|
|
/* ================= 详情 ================= */
|
|
async function loadDetail(id) {
|
|
S.oppId = id; switchTab('detail');
|
|
document.getElementById('detailBox').innerHTML = '<div class="panel loading">加载详情…</div>';
|
|
const d = await tryApi('detail', () => api('GET', '/api/opportunity/detail', { params: { id } }));
|
|
if (!d) { document.getElementById('detailBox').innerHTML = '<div class="panel errbox">详情加载失败</div>'; return; }
|
|
S.detail = d;
|
|
const kv = (k, v) => `<div class="kv"><b>${k}</b><span>${esc(v ?? '—')}</span></div>`;
|
|
document.getElementById('detailBox').innerHTML = `
|
|
<div class="panel">
|
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px">
|
|
<h2 style="font-size:16px">${esc(d.oppName || d.opportunityName)}</h2>${badge(d.oppStatus)}
|
|
<span class="sp" style="flex:1"></span>
|
|
<span class="hint">id=${d.id}</span>
|
|
</div>
|
|
<div class="grid">
|
|
${kv('负责人', d.ownerNameSnapshot)}${kv('所属部门', d.ownerDeptName) + mark('D-13')}
|
|
${kv('商机来源', d.oppSourceName || dictName('opp_source', d.oppSource))}
|
|
${kv('行业', d.industryName || d.industryCode)}
|
|
${kv('招标形式', dictName('bid_form', d.bidForm))}${kv('项目属地', dictName('locality_type', d.localityType))}
|
|
${kv('项目区域', (d.provinceName || d.provinceCode) ? [d.provinceName || d.provinceCode, d.cityName || d.cityCode].filter(Boolean).join(' · ') : '—')}
|
|
${kv('主要意向客户', d.primaryCustomerNameSnapshot)}${kv('关联线索', d.sourceLeadId || '—')}
|
|
${kv('项目金额', d.projectAmount ?? '—')}${kv('方案预算', d.schemeBudget ?? '—') + mark('D-16', 'D-17')}
|
|
${kv('最近有效跟进', d.lastValidFollowTime || '—') + mark('D-16')}${kv('创建时间', d.createTime)}
|
|
${kv('备注', d.remark)}
|
|
</div>
|
|
<div id="stageBar" class="stagebar"></div>
|
|
<div class="toolbar" id="flowBar"></div>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="subtabs" id="subTabs"></div>
|
|
<div id="subBox"><div class="empty">…</div></div>
|
|
</div>`;
|
|
renderStage(d); renderFlowBar(d); renderSubTabs(); loadSub('follow');
|
|
}
|
|
async function renderStage(d) {
|
|
const p = await tryApi('progress', () => api('GET', '/api/opportunity/stage/progress', { params: { oppId: d.id } }), true);
|
|
S.stageNodes = (p && p.nodes) || [];
|
|
const box = document.getElementById('stageBar');
|
|
if (!p || !p.nodes || !p.nodes.length) { box.innerHTML = '<span class="hint">阶段进度不可用(仅推进中/暂缓有阶段概念)</span>'; return; }
|
|
box.innerHTML = `<span class="hint">模板 ${esc(p.stageTemplateVersion || '')}:</span>` + p.nodes.map(n =>
|
|
`<span class="node ${n.nodeState === 'COMPLETED' ? 'done' : n.nodeState === 'CURRENT' ? 'cur' : ''}">${esc(n.customNodeName || dictName('opp_stage', n.stageDictCode))}</span>`).join('⇢') +
|
|
(p.currentWorkGoal ? `<span class="hint">当前目标:${esc(p.currentWorkGoal)}</span>` : '');
|
|
}
|
|
function renderFlowBar(d) {
|
|
const st = d.oppStatus;
|
|
const btn = (label, on, fn) => `<button ${on ? '' : 'disabled'} onclick="${fn}">${label}</button>`;
|
|
document.getElementById('flowBar').innerHTML =
|
|
btn('领取', st === 1, "doTransition('claim')") + mark('D-09') +
|
|
btn('分配', st === 1, 'dlgAssign()') +
|
|
btn('编辑', st !== 5, 'dlgEdit()') + mark('D-07') +
|
|
btn('暂缓', st === 2, 'dlgPause()') +
|
|
btn('取消暂缓', st === 3, "doTransition('resume')") +
|
|
btn('抛公海', st === 2, 'dlgRelease()') +
|
|
btn('关闭', st === 2 || st === 3, 'dlgClose()') + mark('D-08') +
|
|
btn('重启', st === 4, 'dlgReopen()') +
|
|
btn('移交', st === 2 || st === 3, 'dlgHandover()') +
|
|
btn('切阶段', st === 2 || st === 3, 'dlgSwitchStage()') +
|
|
btn(S.detail && S.detail.followed ? '取消关注' : '关注', true, 'doFocus()') + mark('D-16');
|
|
}
|
|
const SUBS = [['follow', '跟进'], ['survey', '现场勘察'], ['customer', '关联客户'], ['team', '团队成员'],
|
|
['card', '方案卡'], ['attachment', '附件'], ['workplan', '工作计划'], ['oplog', '操作日志'], ['stagehist', '阶段历史']];
|
|
function renderSubTabs() {
|
|
document.getElementById('subTabs').innerHTML = SUBS.map(([k, n]) =>
|
|
`<button class="${S.subTab === k ? 'on' : ''}" onclick="S.subTab='${k}';renderSubTabs();loadSub('${k}')">${n}</button>`).join('');
|
|
}
|
|
async function loadSub(kind) {
|
|
const box = document.getElementById('subBox');
|
|
const id = S.oppId; if (!id) return;
|
|
box.innerHTML = '<div class="loading">加载中…</div>';
|
|
const show = html => { if (S.oppId === id) box.innerHTML = html; };
|
|
const P = { oppId: id, pageNum: 1, pageSize: 50 };
|
|
if (kind === 'follow') {
|
|
const d = await tryApi('follow/page', () => api('POST', '/api/opportunity/follow/page', { form: P }));
|
|
show(((d && d.content || []).map(f => `<div style="border-bottom:1px solid #f0f0f0;padding:6px 0">
|
|
<span class="badge b2">${esc(dictName('follow_way', f.followWay))}</span>
|
|
<span class="hint">${esc(f.followTime)}</span> ${esc(f.followContent)}
|
|
<span class="hint">· ${esc(f.resultTagName || dictName('result_tag', f.resultTag) || '')} · 客户id=${esc(f.customerId)}</span></div>`).join('') || '<div class="empty">无跟进记录</div>')
|
|
+ `<div class="toolbar"><button onclick="dlgFollow()">+ 写跟进</button></div>`);
|
|
} else if (kind === 'survey') {
|
|
const d = await tryApi('survey/page', () => api('POST', '/api/opportunity/site-survey/page', { form: P }));
|
|
show(((d && d.content || []).map(s => `<div style="border-bottom:1px solid #f0f0f0;padding:6px 0">
|
|
<span class="hint">${esc(s.surveyDate)}</span> ${esc(dictName('survey_seq', s.surveySeq))}
|
|
申请方式=${esc(dictName('apply_way', s.applyWay))} 勘察师=${esc(s.engineerName || s.engineerUserId)}
|
|
${esc(s.surveyDesc || '')}</div>`).join('') || '<div class="empty">无勘察记录</div>')
|
|
+ `<div class="toolbar"><button onclick="dlgSurvey()">+ 新增勘察</button></div>`);
|
|
} else if (kind === 'customer') {
|
|
const d = await tryApi('customer/list', () => api('GET', '/api/opportunity/customer/list', { params: { oppId: id } }));
|
|
show((d || []).map(c => `<div class="kv" style="padding:4px 0"><b>${esc(c.customerRoleName || dictName('customer_role', c.customerRole))}</b>
|
|
${esc(c.customerNameSnapshot)}(客户id=${esc(c.customerId)}${c.isPrimaryIntended === 1 ? ',<span class="badge b2">主要意向</span>' : ''})</div>`).join('')
|
|
+ `<div class="toolbar"><button onclick="dlgAddCustomer()">+ 添加关联客户</button></div>`
|
|
|| '<div class="empty">无关联客户</div>');
|
|
} else if (kind === 'team') {
|
|
const d = await tryApi('team/list', () => api('GET', '/api/opportunity/team/list', { params: { oppId: id } }));
|
|
show((d || []).map(t => `<div class="kv" style="padding:4px 0"><b>${esc(t.roleName || t.teamRole || '成员')}</b>${esc(t.userName || t.userId)}</div>`).join('')
|
|
|| '<div class="empty">无团队成员 <span class="hint">(成员管理写接口缺失,见缺陷清单)</span>' + mark('D-04', 'D-05', 'D-06') + '</div>');
|
|
} else if (kind === 'card') {
|
|
const d = await tryApi('card/list', () => api('GET', '/api/opportunity/scheme-card/list', { params: { oppId: id } }));
|
|
const cards = d || [];
|
|
show((cards.map(c => `<div style="border-bottom:1px solid #f0f0f0;padding:6px 0">
|
|
${esc(c.templateName || '')} V${esc(c.templateVersion || '')}
|
|
<span class="badge ${c.cardStatus === 2 ? 'b2' : 'b1'}">${c.cardStatus === 2 ? '已提交' : c.cardStatus === 3 ? '已推送' : '草稿'}</span>
|
|
预算 ¥${esc(c.schemeBudget ?? '—')} <button onclick="loadCardDetail('${c.id}')">详情</button>
|
|
${c.cardStatus === 1 ? `<button onclick="submitCard('${c.id}')">提交</button>` : ''}${c.cardStatus === 2 ? mark('D-17') : ''}</div>`).join('')
|
|
|| '<div class="empty">未创建方案卡</div>')
|
|
+ `<div class="toolbar"><button onclick="dlgCard()">+ 新建方案卡(选模板)</button></div>`);
|
|
} else if (kind === 'attachment') {
|
|
const d = await tryApi('attachment/list', () => api('GET', '/api/opportunity/attachment/list', { params: { oppId: id } }));
|
|
show((d || []).map(a => `<div class="kv" style="padding:4px 0"><b>${esc(dictName('attachment_type', a.attachmentType) || '附件')}</b>${esc(a.fileName || a.fileId)}</div>`).join('')
|
|
|| '<div class="empty">无附件(上传需 MinIO 凭据,E2E 里单独验证)</div>') + mark('D-21');
|
|
} else if (kind === 'oplog') {
|
|
const d = await tryApi('oplog/page', () => api('POST', '/api/opportunity/oplog/page', { form: P }));
|
|
show((d && d.content || []).map(o => `<div style="border-bottom:1px solid #f0f0f0;padding:6px 0">
|
|
<span class="hint">${esc(o.opTime || o.createTime)}</span> 用户${esc(o.opUserId ?? '')}
|
|
${o.opKind === 'FIELD_CHANGE'
|
|
? `${esc(o.entityName || '')}·${esc(o.fieldName || '')}:${esc(o.oldValue ?? '空')} → <b>${esc(o.newValue ?? '空')}</b>`
|
|
: `<b>${({ ROW_ADD: '新增', ROW_DELETE: '删除' }[o.opKind]) || esc(o.opKind || '')}</b> ${esc(o.entityName || '')}`}
|
|
<span class="hint">${esc(o.opDesc || '')}</span></div>`).join('')
|
|
|| '<div class="empty">无操作日志</div>') + mark('D-20');
|
|
} else if (kind === 'workplan') {
|
|
const d = await tryApi('workplan/list', () => api('GET', '/api/opportunity/workplan/list', { params: { oppId: id } }));
|
|
const plans = d || [];
|
|
S._plans = plans;
|
|
show((plans.map(p => {
|
|
const st = wpState(p);
|
|
return `<div style="border-bottom:1px solid #f0f0f0;padding:6px 0">
|
|
${st.badge} ${esc(p.planContent)}
|
|
<span class="hint">截止 ${esc(p.deadline || '未设')}${p.finishTime ? ` · 完成于 ${esc(p.finishTime)}` : ''}${st.rest ? ` · ${st.rest}` : ''}</span>
|
|
<button onclick="wpFinish('${p.id}', ${p.planStatus === 1 ? 0 : 1})">${p.planStatus === 1 ? '取消完成' : '登记完成'}</button>
|
|
<button onclick="dlgWorkPlanEdit('${p.id}')">编辑</button>
|
|
<button onclick="wpDelete('${p.id}')">删除</button></div>`;
|
|
}).join('') || '<div class="empty">无工作计划</div>')
|
|
+ `<div class="toolbar"><button onclick="dlgWorkPlan()">+ 新增工作计划</button>${S.detail && S.detail.oppStatus === 3 ? '<span class="hint" style="align-self:center">当前商机暂缓中:写操作将被后端 66003 守卫拦截(原样体验)</span>' : ''}</div>`
|
|
+ `<div class="hint">逾期/临期由前端按 deadline 实时判定(实时算不落库);「登记完成」= update planStatus=1,finishTime 由服务端回填;删除为软删</div>`);
|
|
} else if (kind === 'stagehist') {
|
|
const d = await tryApi('stage/history', () => api('GET', '/api/opportunity/stage/history', { params: { oppId: id } }));
|
|
show((d || []).map(h => `<div style="padding:4px 0;border-bottom:1px solid #f0f0f0">
|
|
<span class="hint">${esc(h.createTime || h.switchTime)}</span> ${esc(h.fromStageName || '')} → <b>${esc(h.toStageName || h.toStageId)}</b> ${esc(h.remark || '')}</div>`).join('')
|
|
|| '<div class="empty">无阶段切换记录</div>');
|
|
}
|
|
}
|
|
async function loadCardDetail(cid) {
|
|
const d = await tryApi('card/detail', () => api('GET', '/api/opportunity/scheme-card/detail', { params: { id: cid } }));
|
|
if (!d) return;
|
|
const vals = (d.fields || d.values || d.fieldValues || []).map(v => `<div class="kv"><b>${esc(v.fieldName || v.fieldKey)}</b>${esc(v.fieldValue)}</div>`).join('');
|
|
openDlg('方案卡详情', `<div class="grid">${vals || '<span class="hint">无填写值</span>'}</div>`, []);
|
|
}
|
|
</script>
|
|
<script>
|
|
/* ================= part2:对话框 + 流转操作 + 子表写操作 + 规则配置 ================= */
|
|
|
|
/* ---------- 对话框基础设施 ---------- */
|
|
const dlgEl = document.getElementById('dlg');
|
|
function openDlg(title, bodyHtml, buttons) {
|
|
document.getElementById('dlgForm').innerHTML =
|
|
`<div class="dhead">${esc(title)}</div><div class="dbody">${bodyHtml}</div>` +
|
|
`<div class="dfoot">` + buttons.map((b, i) => `<button type="button" class="${b.cls || ''}" data-i="${i}">${esc(b.t)}</button>`).join('') +
|
|
`<button type="button" onclick="closeDlg()">取消</button></div>`;
|
|
dlgEl._btns = buttons;
|
|
document.querySelectorAll('#dlgForm button[data-i]').forEach(b =>
|
|
b.onclick = () => { const f = dlgEl._btns[+b.dataset.i].fn; if (f) f(); });
|
|
dlgEl.showModal();
|
|
}
|
|
function closeDlg() { dlgEl.close(); }
|
|
function v(name) { const el = dlgEl.querySelector(`[name="${name}"]`); return el ? el.value.trim() : ''; }
|
|
function fld(label, inner) { return `<label><b>${label}</b>${inner}</label>`; }
|
|
function mark(...ids) { return ` <span class="mark" title="${ids.join(' / ')}:已知缺陷,见 e2e-report.md 第四章">${ids.join('/')}</span>`; }
|
|
function userOpts(sel) {
|
|
return USERS.map(u => `<option value="${u.uid}" ${u.uid === sel ? 'selected' : ''}>${esc(u.name)}</option>`).join('');
|
|
}
|
|
function errHtml(e) {
|
|
const known = { 60001: '(D-01 fail-fast:无发布中的商机阶段模板)', 66001: '(入参校验)',
|
|
66003: '(暂缓中禁写守卫)', 66014: '(禁领规则)' }[e.code] || '';
|
|
return `<div class="errbox">后端返回 code ${e.code ?? '—'}${known}:<br>${esc(e.message)}</div>`;
|
|
}
|
|
async function loadProvinces() {
|
|
if (S.provs) return;
|
|
S.provs = (await tryApi('region/list', () => api('GET', '/api/rule/region/list'), true)) || [];
|
|
}
|
|
async function onProvChange(sel) {
|
|
const prov = (S.provs || []).find(p => p.code === sel.value);
|
|
const citySel = dlgEl.querySelector('[name="cityCode"]');
|
|
if (!citySel) return;
|
|
citySel.innerHTML = '<option value="">加载中…</option>';
|
|
S.cities = [];
|
|
const distSel = dlgEl.querySelector('[name="districtCode"]');
|
|
if (distSel) distSel.innerHTML = '<option value="">(不选,用市码)</option>';
|
|
if (!prov) return;
|
|
S.cities = (await tryApi('region/child', () => api('GET', '/api/rule/region/list', { params: { parentId: prov.id } }), true)) || [];
|
|
citySel.innerHTML = '<option value="">—必选—</option>' +
|
|
S.cities.map(c => `<option value="${esc(c.code)}">${esc(c.name)}</option>`).join('');
|
|
}
|
|
async function onCityChange(sel) {
|
|
const city = (S.cities || []).find(c => c.code === sel.value);
|
|
const distSel = dlgEl.querySelector('[name="districtCode"]');
|
|
if (!distSel) return;
|
|
distSel.innerHTML = '<option value="">加载中…</option>';
|
|
if (!city) { distSel.innerHTML = '<option value="">(不选,用市码)</option>'; return; }
|
|
const d = (await tryApi('region/child2', () => api('GET', '/api/rule/region/list', { params: { parentId: city.id } }), true)) || [];
|
|
distSel.innerHTML = '<option value="">(不选,用市码)</option>' +
|
|
d.map(c => `<option value="${esc(c.code)}">${esc(c.name)}</option>`).join('') +
|
|
(d.length ? '' : '<option value="" disabled>(该市无区县数据)</option>');
|
|
}
|
|
async function reloadAll() { loadList(1); loadBoard(); if (S.oppId) loadDetail(S.oppId); }
|
|
|
|
/* ---------- 添加关联客户(票04 客户信息区联调:类型筛选/搜索/联系人级联/角色/主要) ---------- */
|
|
let _custPicked = null;
|
|
async function dlgAddCustomer() {
|
|
_custPicked = null;
|
|
openDlg('添加关联客户', `
|
|
${fld('客户类型(筛选)', `<select name="customerType"><option value="">全部客户类型</option>${dictOpts('customer_type')}</select>`)}
|
|
${fld('关联客户 *', `<div style="display:flex;gap:6px"><input name="custKw" placeholder="输入客户名关键字" style="flex:1"><button type="button" onclick="doCustSearch()">搜索</button></div>
|
|
<div id="custResult" style="max-height:160px;overflow:auto;margin-top:6px"></div>
|
|
<div id="custPicked" class="hint">未选择客户</div>`)}
|
|
${fld('客户联系人(选客户后级联)', `<select name="customerContactId" id="custContactSel"><option value="">—先选客户—</option></select>`)}
|
|
${fld('客户角色 *', `<select name="customerRole"><option value="">—必选—</option>${dictOpts('customer_role')}</select>`)}
|
|
${fld('是否主要客户 *', '<select name="isPrimaryIntended"><option value="0">否</option><option value="1">是</option></select>')}
|
|
<div id="addCustErr"></div>
|
|
<div class="hint">类型/角色下拉取自 dict enabled-list(customer_type/customer_role);搜索走 POST /api/opportunity/customer/search;联系人级联走 GET /api/opportunity/customer/contacts?customerId=(A4 前为降级 stub,返回空属正常);提交走 POST /api/opportunity/customer/add</div>`,
|
|
[{ t: '确定关联', cls: 'ok', fn: doAddCustomer }]);
|
|
}
|
|
async function doCustSearch() {
|
|
const box = document.getElementById('custResult');
|
|
box.innerHTML = '搜索中…';
|
|
const d = await tryApi('customer/search', () => api('POST', '/api/opportunity/customer/search',
|
|
{ form: { oppId: S.oppId, keyword: v('custKw') || '', pageNum: 1, pageSize: 10 } }));
|
|
const rows = (d && (d.content || d.records)) || [];
|
|
box.innerHTML = rows.map(c =>
|
|
`<div class="kv" style="cursor:pointer;padding:4px 6px" onclick='pickCust(${JSON.stringify(String(c.customerId))},${JSON.stringify(String(c.customerName || ''))})'>· ${esc(c.customerName || c.customerId)} <span class="hint">id=${esc(c.customerId)}</span></div>`).join('')
|
|
|| '<div class="empty">无匹配客户</div>';
|
|
}
|
|
async function pickCust(cid, cname) {
|
|
_custPicked = { customerId: cid, customerName: cname };
|
|
document.getElementById('custPicked').innerHTML = `已选:<b>${esc(cname)}</b>(id=${esc(cid)})`;
|
|
// 级联刷新客户联系人(切换客户 → 重新拉取 → 清空重选)
|
|
const sel = document.getElementById('custContactSel');
|
|
sel.innerHTML = '<option value="">加载中…</option>';
|
|
const contacts = await tryApi('customer/contacts', () => api('GET', '/api/opportunity/customer/contacts', { params: { customerId: cid } }), true) || [];
|
|
sel.innerHTML = '<option value="">(可空)</option>' + contacts.map(ct =>
|
|
`<option value="${esc(ct.contactId)}">${esc(ct.name)}${ct.company ? '(' + esc(ct.company) + ')' : ''}</option>`).join('')
|
|
+ (contacts.length ? '' : '<!-- stub 空 -->');
|
|
if (!contacts.length) sel.insertAdjacentHTML('beforeend', '<option value="" disabled>(A4 前 stub 无联系人,级联已触发)</option>');
|
|
}
|
|
async function doAddCustomer() {
|
|
const err = document.getElementById('addCustErr');
|
|
if (!_custPicked) { err.innerHTML = '<div class="errbox">请先搜索并选择关联客户</div>'; return; }
|
|
if (!v('customerRole')) { err.innerHTML = '<div class="errbox">客户角色必选</div>'; return; }
|
|
const form = { oppId: S.oppId, customerId: _custPicked.customerId,
|
|
customerNameSnapshot: _custPicked.customerName, customerRole: v('customerRole'),
|
|
isPrimaryIntended: v('isPrimaryIntended') };
|
|
const ok = await okApi('customer/add', () => api('POST', '/api/opportunity/customer/add', { form }));
|
|
if (ok) { toast('关联客户成功', 'ok'); closeDlg(); loadSub('customer'); }
|
|
}
|
|
|
|
/* ---------- 新建商机(opp-create-fields 后:7 类字段全量对齐 A3-1-1-2-1,区域三级级联折叠进 cityCode) ---------- */
|
|
async function dlgCreate() {
|
|
await loadProvinces();
|
|
openDlg('新建商机', `
|
|
${fld('商机名称 *', '<input name="opportunityName" placeholder="必填;六必填缺失提交将得到 66001 逐字段提示">')}
|
|
${fld('商机来源 *', `<select name="oppSource" onchange="onCrtSource(this)"><option value="">—必选—</option>${dictOpts('opp_source')}</select>`)}
|
|
<div id="crtLeadRow" style="display:none">${fld('关联线索 *', '<select name="sourceLeadId"><option value="">加载中…</option></select>')}</div>
|
|
${fld('项目区域(省) *', `<select name="provinceCode" onchange="onProvChange(this)"><option value="">—必选—</option>${(S.provs || []).map(p => `<option value="${esc(p.code)}">${esc(p.name)}</option>`).join('')}</select>`)}
|
|
${fld('项目区域(市) *', '<select name="cityCode" onchange="onCityChange(this)"><option value="">—先选省—</option></select>')}
|
|
${fld('区/县(可空)', '<select name="districtCode"><option value="">(不选,用市码)</option></select>')}
|
|
${fld('项目属地 *', `<select name="localityType"><option value="">—必选—</option>${dictOpts('locality_type')}</select>`)}
|
|
${fld('招标形式 *', `<select name="bidForm"><option value="">—必选—</option>${dictOpts('bid_form')}</select>`)}
|
|
${fld('行业', `<select name="industryCode"><option value="">—</option>${dictOpts('industry')}</select>`)}
|
|
${fld('甲方明确', '<select name="partyAClear"><option value="0">否</option><option value="1">是</option></select>')}
|
|
${fld('甲方', '<input name="partyA">')}
|
|
${fld('项目金额', '<input name="projectAmount" type="number" step="0.01" min="0" placeholder="选填,元">')}
|
|
${fld('项目详细地址', '<input name="address" placeholder="选填;省市区之外的门牌地址">')}
|
|
<fieldset><legend>关联客户(可选;填了 ID 则客户类型/是否主要必填)</legend>
|
|
${fld('关联客户ID(可空)', '<input name="customerId" placeholder="A4 未建:手输 id;customer/search 仅详情场景可用(oppId 必填,新建传 0 返回 66002)">')}
|
|
${fld('客户名快照', '<input name="customerName" placeholder="与客户 ID 配套;引用 id + 名快照走法">')}
|
|
${fld('客户类型 *', `<select name="customerRole"><option value="">—</option>${dictOpts('customer_role')}</select>`)}
|
|
${fld('是否主要客户 *', '<select name="isPrimaryIntended"><option value="">—</option><option value="1">主要意向客户</option><option value="0">普通关联客户</option></select>')}
|
|
${fld('关键联系人', '<input name="keyContactName" placeholder="姓名快照;A4 未建,keyContactId 暂留空">')}
|
|
${fld('关键人所在公司', '<input name="keyContactCompany" placeholder="公司快照">')}
|
|
</fieldset>
|
|
${fld('项目概况', '<textarea name="remark" placeholder="原「备注」,同字段改名非新列"></textarea>')}
|
|
<div id="crtErr"></div>
|
|
<div class="hint">POST /api/opportunity;oppSource=线索转入时关联线索必填;区/县选中则区县码作 cityCode(不单建 districtCode);填了客户 ID 时客户类型/是否主要必填(后端 66001);=0 普通关联:创建完成允许零主要意向客户,主表主要客户冗余留空;无发布中模板 60001 fail-fast(D-01)</div>`,
|
|
[{ t: '创建', cls: 'ok', fn: doCreate }]);
|
|
}
|
|
async function onCrtSource(sel) {
|
|
const row = document.getElementById('crtLeadRow');
|
|
if (sel.value !== 'opp_source_01') { row.style.display = 'none'; return; }
|
|
row.style.display = '';
|
|
const d = await tryApi('convertible-leads', () => api('GET', '/api/opportunity/convertible-leads'));
|
|
row.querySelector('select').innerHTML = (d || []).map(l =>
|
|
`<option value="${esc(l.id)}">${esc(l.leadName || l.name || l.id)}</option>`).join('') || '<option value="">(无可关联线索)</option>';
|
|
}
|
|
async function doCreate() {
|
|
const err = document.getElementById('crtErr');
|
|
const need = [['opportunityName', '商机名称'], ['oppSource', '商机来源'], ['provinceCode', '项目区域(省)'],
|
|
['cityCode', '项目区域(市)'], ['localityType', '项目属地'], ['bidForm', '招标形式']];
|
|
const missing = need.filter(([k]) => !v(k)).map(([, n]) => n);
|
|
if (v('oppSource') === 'opp_source_01' && !v('sourceLeadId')) missing.push('关联线索(来源=线索转入时必填)');
|
|
const hasCust = !!v('customerId');
|
|
if (hasCust && !v('customerRole')) missing.push('客户类型(填了关联客户 ID 时必填)');
|
|
if (hasCust && v('isPrimaryIntended') === '') missing.push('是否主要客户(填了关联客户 ID 时必填)');
|
|
if (missing.length) {
|
|
err.innerHTML = `<div class="errbox">必填缺失(与后端 66001 逐字段校验同款):<br>· ${missing.map(esc).join('<br>· ')}</div>`;
|
|
return;
|
|
}
|
|
const form = { opportunityName: v('opportunityName'), oppSource: v('oppSource'),
|
|
provinceCode: v('provinceCode'),
|
|
cityCode: v('districtCode') || v('cityCode'),
|
|
localityType: v('localityType'), bidForm: v('bidForm') };
|
|
['sourceLeadId', 'industryCode', 'partyAClear', 'partyA', 'remark',
|
|
'projectAmount', 'address', 'customerRole', 'keyContactName', 'keyContactCompany',
|
|
'customerId', 'customerName']
|
|
.forEach(k => { if (v(k) !== '') form[k] = v(k); });
|
|
if (hasCust) form.isPrimaryIntended = v('isPrimaryIntended');
|
|
let newId;
|
|
try { newId = await api('POST', '/api/opportunity', { form }); }
|
|
catch (e) { err.innerHTML = errHtml(e); return; }
|
|
closeDlg();
|
|
toast(`商机已创建,id=${newId}(初始 oppStatus=2 推进中;已自动打开详情)`, 'ok');
|
|
loadList(1); loadBoard();
|
|
loadDetail(newId);
|
|
}
|
|
|
|
/* ---------- 自定义视图保存(JSON body + scopeKey @RequestParam) ---------- */
|
|
function dlgSaveView() {
|
|
const kw = document.getElementById('fKeyword').value.trim();
|
|
const st = document.getElementById('fStatus').value;
|
|
openDlg('存为自定义视图', `
|
|
${fld('视图名称 *', '<input name="name" placeholder="如:我的推进中">')}
|
|
<div class="hint">捕获当前筛选:keyword=${esc(kw || '∅')} / statusIn=${esc(st || '∅')}<br>POST /api/preference/view/save?scopeKey=opportunity(JSON body)</div>`,
|
|
[{ t: '保存', cls: 'ok', fn: () => doSaveView(kw, st) }]);
|
|
}
|
|
async function doSaveView(kw, st) {
|
|
const name = v('name');
|
|
if (!name) { toast('请填视图名称', 'err'); return; }
|
|
const conditions = [];
|
|
if (kw) conditions.push({ field: 'keyword', operator: 'like', value: kw });
|
|
if (st) conditions.push({ field: 'statusIn', operator: 'in', value: st });
|
|
if (await okApi('view/save', () => api('POST', '/api/preference/view/save?scopeKey=opportunity',
|
|
{ json: { name, conditions, isDefault: false, seqNo: S.cviews.length + 1 } }))) {
|
|
toast('视图已保存', 'ok'); closeDlg(); loadCviews();
|
|
}
|
|
}
|
|
|
|
/* ---------- 流转操作(状态机 E1-E9) ---------- */
|
|
async function doTransition(act) {
|
|
if (await okApi(act, () => api('POST', `/api/opportunity/${act}`, { form: { id: S.oppId } }))) {
|
|
toast(act === 'claim' ? '领取成功(→推进中)' : '已取消暂缓(→推进中)', 'ok'); reloadAll();
|
|
}
|
|
}
|
|
function dlgAssign() {
|
|
openDlg('分配商机(待领取→推进中,E2)', fld('分配给 *', `<select name="userId">${userOpts()}</select>`),
|
|
[{ t: '分配', cls: 'ok', fn: async () => {
|
|
if (await okApi('assign', () => api('POST', '/api/opportunity/assign', { form: { id: S.oppId, userId: v('userId') } }))) {
|
|
toast('分配成功', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
function dlgPause() {
|
|
openDlg('暂缓(推进中→暂缓中,E4)', `
|
|
${fld('暂缓原因', `<select name="pauseReason"><option value="">—</option>${dictOpts('pause_reason')}</select>`)}
|
|
${fld('预计重启日', '<input name="pauseExpectedRestartDate" type="date">')}
|
|
${fld('暂缓备注', '<textarea name="pauseRemark"></textarea>')}`,
|
|
[{ t: '暂缓', cls: 'ok', fn: async () => {
|
|
const form = { id: S.oppId };
|
|
['pauseReason', 'pauseExpectedRestartDate', 'pauseRemark'].forEach(k => { if (v(k)) form[k] = v(k); });
|
|
if (await okApi('pause', () => api('POST', '/api/opportunity/pause', { form }))) {
|
|
toast('已暂缓', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
function dlgRelease() {
|
|
openDlg('抛公海(推进中→待领取,E3)', fld('公海原因', `<select name="poolReason"><option value="">—</option>${dictOpts('pool_reason')}</select>`),
|
|
[{ t: '抛公海', cls: 'ok', fn: async () => {
|
|
const form = { id: S.oppId };
|
|
if (v('poolReason')) form.poolReason = v('poolReason');
|
|
if (await okApi('release-pool', () => api('POST', '/api/opportunity/release-pool', { form }))) {
|
|
toast('已抛入公海', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
function dlgClose() {
|
|
openDlg('关闭商机(E6)', `
|
|
${fld('关闭原因', `<select name="closeReason"><option value="">—</option>${dictOpts('close_reason')}</select>` + mark('D-08'))}
|
|
${fld('关闭备注', '<textarea name="closeRemark"></textarea>')}
|
|
<div class="hint">暂缓态(3)本不该有 3→4 出边,但后端放行(P1 D-08 原样体验)</div>`,
|
|
[{ t: '关闭', cls: 'ok', fn: async () => {
|
|
const form = { id: S.oppId };
|
|
['closeReason', 'closeRemark'].forEach(k => { if (v(k)) form[k] = v(k); });
|
|
if (await okApi('close', () => api('POST', '/api/opportunity/close', { form }))) {
|
|
toast('已关闭', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
function dlgReopen() {
|
|
openDlg('重启商机(已关闭→推进中,E7)', fld('重启原因', '<input name="reopenReason">'),
|
|
[{ t: '重启', cls: 'ok', fn: async () => {
|
|
const form = { id: S.oppId };
|
|
if (v('reopenReason')) form.reopenReason = v('reopenReason');
|
|
if (await okApi('reopen', () => api('POST', '/api/opportunity/reopen', { form }))) {
|
|
toast('已重启', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
function dlgHandover() {
|
|
const d = S.detail || {};
|
|
openDlg('移交商机(自环换 owner,E9)', `
|
|
${fld('原负责人(校验) *', `<select name="expectedOwnerUserId">${userOpts(d.ownerUserId)}</select>`)}
|
|
${fld('新负责人 *', `<select name="newOwnerUserId">${userOpts()}</select>`)}
|
|
<div class="hint">ownerNameSnapshot 自动取新负责人名;expectedOwnerUserId 须=当前 owner(CAS 守卫)</div>`,
|
|
[{ t: '移交', cls: 'ok', fn: async () => {
|
|
const nu = USERS.find(u => u.uid === v('newOwnerUserId'));
|
|
const form = { id: S.oppId, expectedOwnerUserId: v('expectedOwnerUserId'), newOwnerUserId: v('newOwnerUserId'), ownerNameSnapshot: nu ? nu.name.split('(')[0] : '' };
|
|
if (await okApi('handover', () => api('POST', '/api/opportunity/handover', { form }))) {
|
|
toast('已移交', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
function dlgSwitchStage() {
|
|
const nodes = (S.stageNodes || []).filter(n => n.isFixed !== 1);
|
|
if (!nodes.length) { toast('无可用阶段节点(仅推进中/暂缓有阶段概念)', 'err'); return; }
|
|
openDlg('切换阶段(自由切换:连跳/回退)', `
|
|
${fld('目标节点 *', `<select name="toStageId">${nodes.map(n =>
|
|
`<option value="${n.nodeId}" ${n.nodeState === 'CURRENT' ? 'disabled' : ''}>${esc(n.customNodeName || dictName('opp_stage', n.stageDictCode))}${n.nodeState === 'CURRENT' ? '(当前)' : ''}</option>`).join('')}</select>`)}
|
|
${fld('备注', '<textarea name="remark"></textarea>')}
|
|
<div class="hint">POST /api/opportunity/stage/switch(SwitchStageDTO 表单绑定;固定节点不可切)</div>`,
|
|
[{ t: '切换', cls: 'ok', fn: async () => {
|
|
if (await okApi('switch-stage', () => api('POST', '/api/opportunity/stage/switch',
|
|
{ form: { oppId: S.oppId, toStageId: v('toStageId'), remark: v('remark') } }))) {
|
|
toast('阶段已切换', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
} }]);
|
|
}
|
|
async function doFocus() {
|
|
const d = S.detail || {};
|
|
const focused = d.followed === true;
|
|
const act = focused ? 'unfocus' : 'focus';
|
|
if (await okApi(act, () => api('POST', `/api/opportunity/${act}`, { form: { oppId: S.oppId } }))) {
|
|
d.followed = !focused;
|
|
toast(focused ? '已取消关注' : '已关注', 'ok'); renderFlowBar(d);
|
|
}
|
|
}
|
|
|
|
/* ---------- 编辑(全量覆盖;D-07 暂缓态不拦截) ---------- */
|
|
function dlgEdit() {
|
|
const d = S.detail;
|
|
if (!d) { toast('请先进详情', 'err'); return; }
|
|
openDlg('编辑商机', `
|
|
${fld('商机名称', `<input name="oppName" value="${esc(d.oppName || '')}">`)}
|
|
${fld('商机来源', `<select name="oppSource"><option value="">—</option>${dictOpts('opp_source', d.oppSource)}</select>`)}
|
|
${fld('行业', `<select name="industryCode"><option value="">—</option>${dictOpts('industry', d.industryCode)}</select>`)}
|
|
${fld('招标形式', `<select name="bidForm"><option value="">—</option>${dictOpts('bid_form', d.bidForm)}</select>`)}
|
|
${fld('项目属地', `<select name="localityType"><option value="">—</option>${dictOpts('locality_type', d.localityType)}</select>`)}
|
|
${fld('甲方', `<input name="partyA" value="${esc(d.partyA || '')}">`)}
|
|
${fld('项目金额', `<input name="projectAmount" type="number" value="${d.projectAmount ?? ''}">`)}
|
|
${fld('备注', `<textarea name="remark">${esc(d.remark || '')}</textarea>`)}
|
|
<div class="hint">POST /api/opportunity/edit(全量字段覆盖回传,LocalDateTime 须 ISO);暂缓态可编辑=P1 D-07 原样体验</div>`,
|
|
[{ t: '保存', cls: 'ok', fn: doEdit }]);
|
|
}
|
|
async function doEdit() {
|
|
const d = S.detail, form = {};
|
|
Object.entries(d).forEach(([k, val]) => {
|
|
if (val == null || typeof val === 'object' || k === 'followed' || k === 'editable') return;
|
|
form[k] = val;
|
|
});
|
|
['oppName', 'oppSource', 'industryCode', 'bidForm', 'localityType', 'partyA', 'projectAmount', 'remark']
|
|
.forEach(k => { const x = v(k); if (x !== '') form[k] = x; });
|
|
if (await okApi('edit', () => api('POST', '/api/opportunity/edit', { form }))) {
|
|
toast('已保存', 'ok'); closeDlg(); reloadAll();
|
|
}
|
|
}
|
|
|
|
/* ---------- 跟进 / 勘察 / 方案卡 ---------- */
|
|
async function dlgFollow() {
|
|
const custs = await tryApi('customer/list', () => api('GET', '/api/opportunity/customer/list', { params: { oppId: S.oppId } }), true) || [];
|
|
const now = new Date(); const pad = x => String(x).padStart(2, '0');
|
|
const dt = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
|
|
openDlg('写跟进', `
|
|
${fld('跟进内容 *', '<textarea name="followContent"></textarea>')}
|
|
${fld('跟进方式', `<select name="followWay">${dictOpts('follow_way')}</select>`)}
|
|
${fld('跟进时间 *', `<input name="followTime" type="datetime-local" value="${dt}" step="1">`)}
|
|
${fld('客户 *', `<select name="customerId">${custs.map(c => `<option value="${esc(c.customerId)}">${esc(c.customerNameSnapshot || c.customerId)}</option>`).join('') || '<option value="">(无关联客户)</option>'}</select>` + mark('D-01'))}
|
|
${fld('结果标签', `<select name="resultTag"><option value="">—</option>${dictOpts('result_tag')}</select>`)}
|
|
<div class="hint">customerId 必填;无关联客户时无法满足(添加端点 D-01 404,seed 经 DB 补入)</div>`,
|
|
[{ t: '保存', cls: 'ok', fn: doFollow }]);
|
|
}
|
|
async function doFollow() {
|
|
if (!v('followContent') || !v('customerId')) { toast('跟进内容与客户必填', 'err'); return; }
|
|
const fts = v('followTime').replace('T', ' ');
|
|
const form = { oppId: S.oppId, followContent: v('followContent'), followWay: v('followWay'),
|
|
followTime: fts.length === 16 ? fts + ':00' : fts, customerId: v('customerId') };
|
|
if (v('resultTag')) form.resultTag = v('resultTag');
|
|
if (await okApi('follow/add', () => api('POST', '/api/opportunity/follow/add', { form }))) {
|
|
toast('跟进已写入(刷新回收锚点)', 'ok'); closeDlg(); loadDetail(S.oppId);
|
|
}
|
|
}
|
|
function dlgSurvey() {
|
|
const today = new Date(); const pad = x => String(x).padStart(2, '0');
|
|
const todayStr = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
|
|
openDlg('新增现场勘察', `
|
|
${fld('勘察日期 *', `<input name="surveyDate" type="date" value="${todayStr}">`)}
|
|
${fld('勘察师用户ID *', '<input name="engineerUserId" placeholder="如 A=744842565802524672">')}
|
|
${fld('申请方式', `<select name="applyWay">${dictOpts('apply_way')}</select>`)}
|
|
${fld('勘察次数 *', '<input name="surveySeq" placeholder="如 1">' + mark('D-18'))}
|
|
${fld('申请编号', '<input name="applyNo">')}
|
|
${fld('补充说明', '<textarea name="surveyDesc"></textarea>' + mark('D-19'))}
|
|
<div class="hint">D-18:次数须调用方显式传入(与拍板相反);D-19:空说明不拦(与拍板相反)</div>`,
|
|
[{ t: '保存', cls: 'ok', fn: doSurvey }]);
|
|
}
|
|
async function doSurvey() {
|
|
if (!v('surveyDate') || !v('engineerUserId') || !v('surveySeq')) { toast('日期/勘察师ID/勘察次数必填(D-18:次数须显式传)', 'err'); return; }
|
|
const form = { oppId: S.oppId, surveyDate: v('surveyDate'), engineerUserId: v('engineerUserId'),
|
|
applyWay: v('applyWay'), surveySeq: v('surveySeq') };
|
|
['applyNo', 'surveyDesc'].forEach(k => { if (v(k)) form[k] = v(k); });
|
|
if (await okApi('survey/add', () => api('POST', '/api/opportunity/site-survey/add', { form }))) {
|
|
toast('勘察已记录', 'ok'); closeDlg(); loadSub('survey');
|
|
}
|
|
}
|
|
async function dlgCard() {
|
|
const tpls = await tryApi('selectable-templates', () => api('GET', '/api/opportunity/scheme-card/selectable-templates', { params: { oppId: S.oppId } })) || [];
|
|
if (!tpls.length) { toast('无可选模板版本(发布中且范围命中)——建卡入口置灰', 'err'); return; }
|
|
openDlg('新建方案卡(手动选模板版本)', `
|
|
${fld('模板版本 *', `<select name="templateId" onchange="onTplPick()">${tpls.map(t =>
|
|
`<option value="${esc(t.templateId)}">${esc(t.templateName || '')} V${esc(t.templateVersion || '')}</option>`).join('')}</select>`)}
|
|
${fld('客户ID *', '<input name="customerId" placeholder="须为本商机下关联客户(一客户一卡 66009)">' + mark('D-01'))}
|
|
<div id="cardFields"></div>
|
|
<div class="hint">valuesJson 格式=[{"fieldKey":"...","fieldValue":"..."}],只认绑定模板显示字段(66007)</div>`,
|
|
[{ t: '保存草稿', cls: 'ok', fn: () => doCardSave(0) }, { t: '保存并提交', fn: () => doCardSave(1) }]);
|
|
onTplPick();
|
|
}
|
|
async function onTplPick() {
|
|
const tid = v('templateId');
|
|
if (!tid) return;
|
|
const d = await tryApi('tpl-detail', () => api('GET', '/api/rule/opp-scheme-template/detail', { params: { id: tid } }), true);
|
|
const fs = ((d && d.fields) || []).filter(f => f.isVisible === 1);
|
|
document.getElementById('cardFields').innerHTML =
|
|
(fs.length ? '<div class="hint">显示字段(* 为模板必填):</div>' : '<div class="hint">该模板无显示字段</div>') +
|
|
fs.map(f => `<label><b>${esc(f.fieldName || f.fieldKey)}${f.isRequired === 1 ? ' *' : ''}</b><input name="vf_${esc(f.fieldKey)}"></label>`).join('');
|
|
}
|
|
async function doCardSave(submit) {
|
|
if (!v('customerId')) { toast('客户ID必填(一客户一卡约束 66009)', 'err'); return; }
|
|
const vj = [...dlgEl.querySelectorAll('[name^="vf_"]')].filter(i => i.value.trim())
|
|
.map(i => ({ fieldKey: i.name.slice(3), fieldValue: i.value.trim() }));
|
|
const form = { oppId: S.oppId, templateId: v('templateId'), customerId: v('customerId'), valuesJson: JSON.stringify(vj) };
|
|
const id = await tryApi('card/save', () => api('POST', '/api/opportunity/scheme-card/save', { form }));
|
|
if (id == null) return;
|
|
if (submit) await tryApi('card/submit', () => api('POST', '/api/opportunity/scheme-card/submit', { params: { id } }));
|
|
toast(submit ? '方案卡已提交(主表回显滞后=D-17)' : '方案卡草稿已保存', 'ok'); closeDlg(); loadSub('card');
|
|
}
|
|
async function submitCard(id) {
|
|
if (await okApi('card/submit', () => api('POST', '/api/opportunity/scheme-card/submit', { params: { id } }))) {
|
|
toast('方案卡已提交', 'ok'); loadSub('card');
|
|
}
|
|
}
|
|
|
|
/* ---------- 工作计划(票 12 四端点:list/add/update/delete) ---------- */
|
|
function wpState(p) {
|
|
if (p.planStatus === 1) return { badge: '<span class="badge b2">已完成</span>' };
|
|
if (!p.deadline) return { badge: '<span class="badge b1">未完成</span>' };
|
|
const now = Date.now(), dl = new Date(p.deadline.replace(' ', 'T')).getTime();
|
|
if (now > dl) {
|
|
const days = Math.floor((now - dl) / 86400000);
|
|
return { badge: '<span class="badge b4">逾期</span>', rest: `已逾期 ${days} 天` };
|
|
}
|
|
const left = dl - now;
|
|
if (left <= 3 * 86400000) return { badge: '<span class="badge b3">临期</span>', rest: `剩 ${Math.max(1, Math.ceil(left / 86400000))} 天` };
|
|
return { badge: '<span class="badge b1">进行中</span>' };
|
|
}
|
|
function dlgWorkPlan() {
|
|
openDlg('新增工作计划', `
|
|
${fld('计划内容 *', '<textarea name="planContent" placeholder="必填,≤1000 字"></textarea>')}
|
|
${fld('截止时间', '<input name="deadline" type="datetime-local" step="1">')}
|
|
<div id="wpErr"></div>
|
|
<div class="hint">POST /api/opportunity/workplan/add(表单 DTO 绑定);逾期/临期按 deadline 前端实时判;暂缓中商机会被 66003 拦</div>`,
|
|
[{ t: '保存', cls: 'ok', fn: () => doWorkPlanSave(null) }]);
|
|
}
|
|
function dlgWorkPlanEdit(pid) {
|
|
const p = (S._plans || []).find(x => String(x.id) === String(pid));
|
|
if (!p) { toast('行数据已过期,请刷新列表', 'err'); return; }
|
|
openDlg('编辑工作计划(部分更新:未传字段不动)', `
|
|
${fld('计划内容', `<textarea name="planContent">${esc(p.planContent || '')}</textarea>`)}
|
|
${fld('截止时间', `<input name="deadline" type="datetime-local" step="1" value="${p.deadline ? esc(p.deadline.replace(' ', 'T')) : ''}">`)}
|
|
${fld('计划状态', `<select name="planStatus"><option value="">—不改—</option><option value="1" ${p.planStatus === 1 ? 'selected' : ''}>1 已完成(服务端回填 finishTime)</option><option value="0" ${p.planStatus === 0 ? 'selected' : ''}>0 未完成(清空 finishTime)</option></select>`)}
|
|
<div id="wpErr"></div>
|
|
<div class="hint">POST /api/opportunity/workplan/update(值域 0/1,其余 66001)</div>`,
|
|
[{ t: '保存', cls: 'ok', fn: () => doWorkPlanSave(pid) }]);
|
|
}
|
|
async function doWorkPlanSave(pid) {
|
|
const err = document.getElementById('wpErr');
|
|
const form = {};
|
|
if (!pid) {
|
|
if (!v('planContent')) { err.innerHTML = '<div class="errbox">计划内容必填(后端 66001 同款校验)</div>'; return; }
|
|
form.oppId = S.oppId; form.planContent = v('planContent');
|
|
} else {
|
|
form.id = pid;
|
|
if (v('planContent')) form.planContent = v('planContent');
|
|
if (v('planStatus') !== '') form.planStatus = v('planStatus');
|
|
}
|
|
if (v('deadline')) { const dl = v('deadline').replace('T', ' '); form.deadline = dl.length === 16 ? dl + ':00' : dl; }
|
|
try {
|
|
if (!pid) await api('POST', '/api/opportunity/workplan/add', { form });
|
|
else await api('POST', '/api/opportunity/workplan/update', { form });
|
|
} catch (e) { err.innerHTML = errHtml(e); return; }
|
|
closeDlg();
|
|
toast(pid ? '工作计划已更新' : '工作计划已新增', 'ok');
|
|
loadSub('workplan');
|
|
}
|
|
async function wpFinish(pid, target) {
|
|
try {
|
|
await api('POST', '/api/opportunity/workplan/update', { form: { id: pid, planStatus: target } });
|
|
toast(target === 1 ? '已登记完成(finishTime 由服务端回填)' : '已取消完成(finishTime 已清空)', 'ok');
|
|
} catch (e) {
|
|
toast(`登记失败:${e.message}`, 'err');
|
|
}
|
|
loadSub('workplan');
|
|
}
|
|
async function wpDelete(pid) {
|
|
if (!confirm('确认删除该条工作计划?(软删,列表不再可见)')) return;
|
|
try {
|
|
await api('POST', '/api/opportunity/workplan/delete', { params: { id: pid } });
|
|
toast('已删除(软删)', 'ok');
|
|
} catch (e) {
|
|
toast(`删除失败:${e.message}`, 'err');
|
|
}
|
|
loadSub('workplan');
|
|
}
|
|
|
|
/* ================= 规则配置 tab(三族 V-CONFIG CRUD) ================= */
|
|
const FAM_META = {
|
|
'opp-stage-template': { label: '商机阶段设置', nameKey: 'templateName', descKey: 'templateDesc', codeKey: 'templateCode' },
|
|
'opp-scheme-template': { label: '方案卡模板', nameKey: 'templateName', descKey: 'templateDesc', codeKey: 'templateCode' },
|
|
'opp-pool-rule': { label: '公海与提醒', nameKey: 'ruleName', descKey: 'ruleDesc', codeKey: 'ruleCode' },
|
|
};
|
|
const RSTATUS = { 1: ['草稿', 'b1'], 2: ['发布中', 'b2'], 3: ['已停用', 'b4'] };
|
|
function loadRules() {
|
|
renderRuleBar();
|
|
loadRuleList();
|
|
}
|
|
function renderRuleBar() {
|
|
document.getElementById('ruleBar').innerHTML =
|
|
FAMILIES.map(([k, n]) => `<button class="${S.family === k ? 'on' : ''}" onclick="S.family='${k}';renderRuleBar();loadRuleList()">${n}</button>`).join('') +
|
|
`<span class="sp"></span>
|
|
<input id="ruleKw" placeholder="名称/编码/版本号" style="width:150px">
|
|
<select id="ruleStatus"><option value="">全部状态</option><option value="1">草稿</option><option value="2">发布中</option><option value="3">已停用</option></select>
|
|
<button onclick="loadRuleList()">查询</button>
|
|
<button onclick="dlgRuleForm()">+ 新建草稿</button>`;
|
|
}
|
|
async function loadRuleList() {
|
|
const fam = S.family, m = FAM_META[fam];
|
|
const box = document.getElementById('ruleListBox');
|
|
box.innerHTML = '<div class="loading">加载中…</div>';
|
|
const form = { current: 1, size: 50 };
|
|
const kw = document.getElementById('ruleKw') ? document.getElementById('ruleKw').value.trim() : '';
|
|
const st = document.getElementById('ruleStatus') ? document.getElementById('ruleStatus').value : '';
|
|
if (kw) form.keyword = kw;
|
|
if (st) form.status = st;
|
|
const d = await tryApi(fam + '/page', () => api('POST', `/api/rule/${fam}/page`, { form }));
|
|
if (!d) { box.innerHTML = '<div class="empty">查询失败,见右上提示</div>'; return; }
|
|
const rows = d.content || [];
|
|
box.innerHTML = `<table><thead><tr><th>编码</th><th>名称</th><th>版本</th><th>状态</th><th>适用范围</th><th>适用部门</th><th>默认</th><th>操作</th></tr></thead><tbody>` +
|
|
(rows.map(r => {
|
|
const sta = RSTATUS[r.status] || [r.status, 'b1'];
|
|
return `<tr class="row" onclick="dlgRuleDetail('${r.id}')">
|
|
<td>${esc(r[m.codeKey])}</td><td>${esc(r[m.nameKey])}</td>
|
|
<td>${esc(r.versionNo)}</td><td><span class="badge ${sta[1]}">${sta[0]}</span></td>
|
|
<td>${r.applyScope === 2 ? '部门专用' : '通用'}</td>
|
|
<td>${esc((r.deptNames || []).join('、') || '—')}</td>
|
|
<td>${r.isDefault === 1 ? '⭐' : ''}</td>
|
|
<td onclick="event.stopPropagation()">
|
|
<button onclick="dlgRuleDetail('${r.id}')">详情</button>
|
|
<button onclick="dlgRuleForm('${r.id}')">编辑</button>${r.status === 2 ? mark('D-10', 'D-11', 'D-12') : ''}
|
|
<button onclick="ruleCopy('${r.id}')">复制</button>
|
|
${r.status === 2 ? `<button onclick="ruleDisable('${r.id}')">停用</button>` : ''}
|
|
${r.status === 1 ? `<button onclick="ruleDelete('${r.id}')">删除</button>` : ''}
|
|
<button onclick="dlgRuleVersions('${r.id}')">版本</button>
|
|
</td></tr>`;
|
|
}).join('') || '<tr><td colspan="8" class="empty">无数据(试「+ 新建草稿」)</td></tr>') + '</tbody></table>';
|
|
}
|
|
async function dlgRuleDetail(id) {
|
|
const fam = S.family, m = FAM_META[fam];
|
|
const d = await tryApi('rule-detail', () => api('GET', `/api/rule/${fam}/detail`, { params: { id } }));
|
|
if (!d) return;
|
|
const sta = RSTATUS[d.status] || [d.status, 'b1'];
|
|
let extra = '';
|
|
if (fam === 'opp-stage-template') {
|
|
extra = (d.nodes || []).length
|
|
? `<table><thead><tr><th>#</th><th>阶段</th><th>工作目标</th><th>固定</th></tr></thead><tbody>` +
|
|
d.nodes.map(n => `<tr><td>${n.seqNo}</td><td>${esc(n.customNodeName || dictName('opp_stage', n.stageDictCode))}</td><td>${esc(n.workGoal || '—')}</td><td>${n.isFixed === 1 ? '✔' : ''}</td></tr>`).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">无节点(草稿未配置)</div>';
|
|
} else if (fam === 'opp-scheme-template') {
|
|
extra = (d.fields || []).length
|
|
? `<table><thead><tr><th>#</th><th>字段</th><th>类型</th><th>key</th><th>显示</th><th>必填</th></tr></thead><tbody>` +
|
|
d.fields.map(x => `<tr><td>${x.seqNo}</td><td>${esc(x.fieldName || '')}</td><td>${esc(x.fieldType || '')}</td><td>${esc(x.fieldKey)}</td><td>${x.isVisible === 1 ? '✔' : '—'}</td><td>${x.isRequired === 1 ? '✔' : '—'}</td></tr>`).join('') +
|
|
'</tbody></table>'
|
|
: '<div class="empty">无字段项(空模板 publish 会被 64017 拒)</div>';
|
|
} else {
|
|
extra = '<div class="grid">' + [
|
|
['手动抛公海', d.allowManualPool], ['自动回收', d.autoRecycleEnabled], ['回收天数', d.recycleDays],
|
|
['自由领取', d.allowFreeClaim], ['回收前提醒', d.recycleRemindEnabled], ['提前提醒天数', d.remindDays],
|
|
].map(([k, val]) => `<div class="kv"><b>${k}</b><span>${val == null ? '—' : val}</span></div>`).join('') + '</div>';
|
|
}
|
|
const btns = [];
|
|
if (d.status === 1) btns.push({ t: '发布此草稿', cls: 'ok', fn: async () => {
|
|
if (await okApi('publish', () => api('POST', `/api/rule/${fam}/publish`, { form: ruleDtoForm(fam, d) }))) {
|
|
toast('已发布', 'ok'); closeDlg(); loadRuleList();
|
|
}
|
|
} });
|
|
btns.push({ t: '编辑', fn: () => dlgRuleForm(id) });
|
|
openDlg(`${FAM_META[fam].label} · ${d[m.nameKey]}`, `
|
|
<div class="grid">
|
|
<div class="kv"><b>编码</b><span>${esc(d[m.codeKey])}</span></div>
|
|
<div class="kv"><b>版本</b><span>${esc(d.versionNo)} <span class="badge ${sta[1]}">${sta[0]}</span></span></div>
|
|
<div class="kv"><b>适用范围</b><span>${d.applyScope === 2 ? '部门专用' : '通用'}${d.isDefault === 1 ? ' · ⭐默认' : ''}</span></div>
|
|
<div class="kv"><b>适用部门</b><span>${esc((d.deptNames || []).join('、') || '—')}</span></div>
|
|
<div class="kv"><b>说明</b><span>${esc(d[m.descKey] || '—')}</span></div>
|
|
</div>${extra}`,
|
|
btns);
|
|
}
|
|
function ruleDtoForm(fam, d) {
|
|
const m = FAM_META[fam];
|
|
const f = { id: d.id };
|
|
f[m.nameKey] = d[m.nameKey];
|
|
f.applyScope = d.applyScope;
|
|
f.isDefault = d.isDefault != null ? d.isDefault : 0;
|
|
if (d.applyScope === 2 && d.deptIds) f.deptIds = d.deptIds.join(',');
|
|
if (d[m.descKey]) f[m.descKey] = d[m.descKey];
|
|
if (fam === 'opp-pool-rule') {
|
|
['allowManualPool', 'autoRecycleEnabled', 'recycleDays', 'allowFreeClaim', 'recycleRemindEnabled', 'remindDays']
|
|
.forEach(k => { if (d[k] != null) f[k] = d[k]; });
|
|
}
|
|
if (fam === 'opp-stage-template' && d.nodes) {
|
|
d.nodes.forEach((n, i) => {
|
|
f[`nodes[${i}].stageDictCode`] = n.stageDictCode;
|
|
if (n.customNodeName) f[`nodes[${i}].customNodeName`] = n.customNodeName;
|
|
if (n.workGoal) f[`nodes[${i}].workGoal`] = n.workGoal;
|
|
});
|
|
}
|
|
if (fam === 'opp-scheme-template' && d.fields) {
|
|
d.fields.forEach((x, i) => {
|
|
f[`fields[${i}].fieldKey`] = x.fieldKey;
|
|
f[`fields[${i}].isVisible`] = x.isVisible != null ? x.isVisible : 1;
|
|
f[`fields[${i}].isRequired`] = x.isRequired != null ? x.isRequired : 0;
|
|
});
|
|
}
|
|
return f;
|
|
}
|
|
function sel01(name, d) {
|
|
const val = d ? d[name] : null;
|
|
return `<select name="${name}"><option value="1" ${val === 1 ? 'selected' : ''}>1 是</option><option value="0" ${val === 0 ? 'selected' : ''}>0 否</option></select>`;
|
|
}
|
|
async function dlgRuleForm(id) {
|
|
const fam = S.family, m = FAM_META[fam];
|
|
let d = null;
|
|
if (id) {
|
|
d = await tryApi('rule-detail', () => api('GET', `/api/rule/${fam}/detail`, { params: { id } }), true);
|
|
if (!d) return;
|
|
}
|
|
const isPool = fam === 'opp-pool-rule';
|
|
let body = `
|
|
${fld(isPool ? '规则名称 *' : '模板名称 *', `<input name="nm" value="${esc(d ? d[m.nameKey] || '' : '')}">`)}
|
|
${fld('适用范围', `<select name="applyScope" onchange="document.getElementById('deptRow').style.display=this.value==='2'?'':'none'">
|
|
<option value="1" ${!d || d.applyScope !== 2 ? 'selected' : ''}>1 通用</option>
|
|
<option value="2" ${d && d.applyScope === 2 ? 'selected' : ''}>2 部门专用</option></select>`)}
|
|
${fld('是否默认', `<select name="isDefault"><option value="0">否</option><option value="1" ${d && d.isDefault === 1 ? 'selected' : ''}>是</option></select> <span class="hint">仅通用范围可设</span>`)}
|
|
<div id="deptRow" style="display:${d && d.applyScope === 2 ? '' : 'none'}">
|
|
${fld('适用部门ID', `<input name="deptIds" value="${esc(d && d.deptIds ? d.deptIds.join(',') : '')}" placeholder="逗号分隔(避开 mojibake 存量 744636475731083264)">`)}</div>
|
|
${fld(isPool ? '规则说明' : '模板说明', `<textarea name="desc">${esc(d ? d[m.descKey] || '' : '')}</textarea>`)}`;
|
|
if (isPool) body += `
|
|
${fld('允许手动抛公海', sel01('allowManualPool', d))}
|
|
${fld('启用自动回收', sel01('autoRecycleEnabled', d))}
|
|
${fld('自动回收天数', `<input name="recycleDays" type="number" value="${d && d.recycleDays != null ? d.recycleDays : ''}" placeholder="启用回收时必填 ≥1">`)}
|
|
${fld('允许自由领取', sel01('allowFreeClaim', d) + mark('D-09'))}
|
|
${fld('回收前提醒', sel01('recycleRemindEnabled', d) + ' <span class="hint">autoRecycle=0 时必须显式传 0(否则 64008)</span>')}
|
|
${fld('提前提醒天数', `<input name="remindDays" type="number" value="${d && d.remindDays != null ? d.remindDays : ''}" placeholder="≥1 且 < 回收天数">`)}`;
|
|
if (fam === 'opp-stage-template') body += `
|
|
<div class="hint">阶段节点(按列表序重算 seq;末位固定节点系统自动补,手配固定节点拒 64013)</div>
|
|
<div id="nodeRows"></div>
|
|
<div class="toolbar"><button type="button" onclick="addNodeRow()">+ 加节点</button></div>`;
|
|
if (fam === 'opp-scheme-template') body += `
|
|
<div class="hint">字段项(引系统字段库 field-defs;伪造 fieldKey 拒 64022;关显示同步清必填)</div>
|
|
<div id="fieldRows"></div>
|
|
<div class="toolbar"><button type="button" onclick="addFieldRow()">+ 加字段</button></div>`;
|
|
body += `<div class="hint">POST /api/rule/${fam}/save-draft 或 /publish(表单全量 DTO);「发布中」行编辑将触发 D-10/11/12(50001 主键冲突,原样展示)</div>`;
|
|
const modeName = d ? (d.status === 2 ? '编辑发布中(将尝试生成新草稿)' : d.status === 1 ? '编辑草稿(原位)' : '编辑停用版(新草稿)') : '新建草稿(V1.0)';
|
|
openDlg(`${FAM_META[fam].label} · ${modeName}`, body,
|
|
[{ t: '仅存草稿', cls: 'ok', fn: () => doRuleSave(0, d) }, { t: '保存并发布', fn: () => doRuleSave(1, d) }]);
|
|
if (fam === 'opp-stage-template') {
|
|
(d && d.nodes || []).forEach(n => addNodeRow(n));
|
|
if (!d || !(d.nodes || []).length) addNodeRow();
|
|
}
|
|
if (fam === 'opp-scheme-template') {
|
|
if (!S.fieldDefs) await loadFieldDefs();
|
|
(d && d.fields || []).forEach(f => addFieldRow(f));
|
|
if (!d || !(d.fields || []).length) addFieldRow();
|
|
}
|
|
}
|
|
async function loadFieldDefs() {
|
|
const d = await tryApi('field-defs', () => api('GET', '/api/rule/opp-scheme-template/field-defs'), true);
|
|
S.fieldDefs = d || [];
|
|
}
|
|
function addNodeRow(n) {
|
|
const box = document.getElementById('nodeRows');
|
|
if (!box) return;
|
|
const div = document.createElement('div');
|
|
div.className = 'nrow';
|
|
div.style.cssText = 'display:flex;gap:6px;margin:4px 0;align-items:center';
|
|
div.innerHTML = `<select name="code" style="flex:1"><option value="">—阶段—</option>${dictOpts('opp_stage', n && n.stageDictCode)}</select>
|
|
<input name="cname" placeholder="自定义名" style="width:120px" value="${esc(n && n.customNodeName || '')}">
|
|
<input name="goal" placeholder="工作目标" style="flex:1" value="${esc(n && n.workGoal || '')}">
|
|
<button type="button" onclick="this.parentNode.remove()">删</button>`;
|
|
box.appendChild(div);
|
|
}
|
|
function addFieldRow(f) {
|
|
const box = document.getElementById('fieldRows');
|
|
if (!box) return;
|
|
const defs = S.fieldDefs || [];
|
|
const div = document.createElement('div');
|
|
div.className = 'nrow';
|
|
div.style.cssText = 'display:flex;gap:6px;margin:4px 0;align-items:center';
|
|
div.innerHTML = `<select name="fkey" style="flex:2">${defs.map(x =>
|
|
`<option value="${esc(x.fieldKey)}" ${f && f.fieldKey === x.fieldKey ? 'selected' : ''}>${esc(x.fieldName || x.fieldKey)}(${esc(x.fieldType || '')})</option>`).join('')}</select>
|
|
<label style="display:flex;gap:2px;align-items:center"><input type="checkbox" name="fvis" ${!f || f.isVisible !== 0 ? 'checked' : ''}>显示</label>
|
|
<label style="display:flex;gap:2px;align-items:center"><input type="checkbox" name="freq" ${f && f.isRequired === 1 ? 'checked' : ''}>必填</label>
|
|
<button type="button" onclick="this.parentNode.remove()">删</button>`;
|
|
box.appendChild(div);
|
|
}
|
|
async function doRuleSave(publish, orig) {
|
|
const fam = S.family, m = FAM_META[fam];
|
|
const form = {};
|
|
form[m.nameKey] = v('nm');
|
|
form.applyScope = v('applyScope');
|
|
form.isDefault = v('isDefault');
|
|
if (v('applyScope') === '2') {
|
|
if (!v('deptIds')) { toast('部门专用须至少一个部门ID', 'err'); return; }
|
|
form.deptIds = v('deptIds');
|
|
}
|
|
if (v('desc')) form[m.descKey] = v('desc');
|
|
if (fam === 'opp-pool-rule') {
|
|
['allowManualPool', 'autoRecycleEnabled', 'allowFreeClaim', 'recycleRemindEnabled'].forEach(k => { form[k] = v(k); });
|
|
if (v('recycleDays')) form.recycleDays = v('recycleDays');
|
|
if (v('remindDays')) form.remindDays = v('remindDays');
|
|
}
|
|
if (fam === 'opp-stage-template') {
|
|
const rows = [...dlgEl.querySelectorAll('#nodeRows .nrow')];
|
|
if (!rows.length) { toast('至少一个阶段节点(除固定节点外)', 'err'); return; }
|
|
rows.forEach((r, i) => {
|
|
form[`nodes[${i}].stageDictCode`] = r.querySelector('[name=code]').value;
|
|
const cn = r.querySelector('[name=cname]').value;
|
|
const gl = r.querySelector('[name=goal]').value;
|
|
if (cn) form[`nodes[${i}].customNodeName`] = cn;
|
|
if (gl) form[`nodes[${i}].workGoal`] = gl;
|
|
});
|
|
}
|
|
if (fam === 'opp-scheme-template') {
|
|
const rows = [...dlgEl.querySelectorAll('#fieldRows .nrow')];
|
|
if (!rows.length) { toast('至少一个字段项', 'err'); return; }
|
|
rows.forEach((r, i) => {
|
|
form[`fields[${i}].fieldKey`] = r.querySelector('[name=fkey]').value;
|
|
form[`fields[${i}].isVisible`] = r.querySelector('[name=fvis]').checked ? 1 : 0;
|
|
form[`fields[${i}].isRequired`] = r.querySelector('[name=freq]').checked ? 1 : 0;
|
|
});
|
|
}
|
|
if (orig) form.id = orig.id;
|
|
if (await okApi(publish ? 'publish' : 'save-draft',
|
|
() => api('POST', `/api/rule/${fam}/${publish ? 'publish' : 'save-draft'}`, { form }))) {
|
|
toast(publish ? '已保存并发布' : '草稿已保存', 'ok'); closeDlg(); loadRuleList();
|
|
}
|
|
}
|
|
async function ruleCopy(id) {
|
|
if (await okApi('copy', () => api('POST', `/api/rule/${S.family}/copy`, { params: { id } }))) {
|
|
toast('已复制为独立新规则(新编码 V1.0 草稿)', 'ok'); loadRuleList();
|
|
}
|
|
}
|
|
async function ruleDisable(id) {
|
|
if (await okApi('disable', () => api('POST', `/api/rule/${S.family}/disable`, { params: { id } }))) {
|
|
toast('已停用', 'ok'); loadRuleList();
|
|
}
|
|
}
|
|
async function ruleDelete(id) {
|
|
if (await okApi('delete', () => api('POST', `/api/rule/${S.family}/delete`, { params: { id } }))) {
|
|
toast('草稿已删除(级联子表)', 'ok'); loadRuleList();
|
|
}
|
|
}
|
|
async function dlgRuleVersions(id) {
|
|
const list = await tryApi('versions', () => api('GET', `/api/rule/${S.family}/versions`, { params: { id } })) || [];
|
|
openDlg('版本历史', (list.length
|
|
? `<table><thead><tr><th>编码</th><th>版本</th><th>状态</th><th>创建时间</th></tr></thead><tbody>` +
|
|
list.map(x => {
|
|
const sta = RSTATUS[x.status] || [x.status, 'b1'];
|
|
return `<tr><td>${esc(x.ruleCode || x.templateCode)}</td><td>${esc(x.versionNo)}</td><td><span class="badge ${sta[1]}">${sta[0]}</span></td><td>${esc(x.createTime || '')}</td></tr>`;
|
|
}).join('') + '</tbody></table>'
|
|
: '<div class="empty">无版本</div>') +
|
|
`<div class="hint">GET /versions 出参为实体直出,泄露 creatorId/deleted/updaterId(违 ADR-0017)${mark('D-22')}</div>`, []);
|
|
}
|
|
|
|
/* ---------- 启动引导:填充选人下拉 + 首次自动登录 ---------- */
|
|
(function boot() {
|
|
document.getElementById('userSel').innerHTML =
|
|
USERS.map((u, i) => `<option value="${i}">${esc(u.name)}</option>`).join('');
|
|
const saved = localStorage.getItem('demoUser');
|
|
const idx = saved != null && USERS[+saved] ? +saved : 0;
|
|
document.getElementById('userSel').value = idx;
|
|
S.user = USERS[idx];
|
|
if (!BASE.includes('localhost')) {
|
|
// 测试环境模式:下拉里的开发库 uid 不适用,等用户 uid 直登
|
|
document.getElementById('whoBox').textContent = '测试环境模式:输入 userId 后点「uid 登录」';
|
|
return;
|
|
}
|
|
login();
|
|
})();
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|
|
|