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.
 
 
 
 
 

968 lines
60 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; }
</style>
</head>
<body>
<header>
<h1>商机模块 E2E Demo</h1>
<span>当前用户:</span>
<select id="userSel" onchange="switchUser(this.value)"></select>
<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="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>
</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 = 'http://localhost:8080';
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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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; }
}
/* ================= 登录选人 ================= */
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', '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; 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; 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() {
document.getElementById('boardBox').innerHTML = '<span class="loading">…</span>';
const d = await tryApi('board', () => api('POST', '/api/opportunity/board/stage-summary', { form: { viewType: S.view } }));
document.getElementById('boardBox').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>';
}
/* ================= 详情 ================= */
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.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', '附件'], ['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="empty">无关联客户 <span class="hint">(添加接口缺失,seed 经 DB 补入)</span>' + mark('D-01', 'D-02', 'D-03') + '</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.createTime)}</span> ${esc(o.operatorName || o.operatorId)}
${esc(o.fieldName || '')} ${esc(o.oldValue ?? '')} → <b>${esc(o.newValue ?? '')}</b> ${esc(o.operationDesc || '')}</div>`).join('')
|| '<div class="empty">无操作日志</div>') + mark('D-20');
} 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.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('');
}
async function reloadAll() { loadList(1); loadBoard(); if (S.oppId) loadDetail(S.oppId); }
/* ---------- 新建商机(flat create) ---------- */
function dlgCreate() {
openDlg('新建商机', `
${fld('商机名称 *', '<input name="opportunityName" placeholder="必填;留空提交可看 D-14 真实行为(500 类)">')}
${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="industryCode"><option value="">—</option>${dictOpts('industry')}</select>`)}
${fld('招标形式', `<select name="bidForm"><option value="">—</option>${dictOpts('bid_form')}</select>`)}
${fld('项目属地', `<select name="localityType"><option value="">—</option>${dictOpts('locality_type')}</select>`)}
${fld('甲方明确', '<select name="partyAClear"><option value="0">否</option><option value="1">是</option></select>')}
${fld('甲方', '<input name="partyA">')}
${fld('备注', '<textarea name="remark"></textarea>')}
<div class="hint">POST /api/opportunity(flat create;oppSource=线索转入时关联线索必填)</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() {
if (!v('opportunityName')) { toast('商机名称必填(留空提交可对照 D-14 的 500 类行为)', 'err'); return; }
const form = { opportunityName: v('opportunityName') };
['oppSource', 'sourceLeadId', 'industryCode', 'bidForm', 'localityType', 'partyAClear', 'partyA', 'remark']
.forEach(k => { if (v(k)) form[k] = v(k); });
if (await tryApi('create', () => api('POST', '/api/opportunity', { form })) != null) {
toast('商机已创建(初始 oppStatus=2 推进中,U01 复核)', 'ok'); closeDlg(); reloadAll();
}
}
/* ---------- 自定义视图保存(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 tryApi('view/save', () => api('POST', '/api/preference/view/save?scopeKey=opportunity',
{ json: { name, conditions, isDefault: false, seqNo: S.cviews.length + 1 } })) != null) {
toast('视图已保存', 'ok'); closeDlg(); loadCviews();
}
}
/* ---------- 流转操作(状态机 E1-E9) ---------- */
async function doTransition(act) {
if (await tryApi(act, () => api('POST', `/api/opportunity/${act}`, { form: { id: S.oppId } })) != null) {
toast(act === 'claim' ? '领取成功(→推进中)' : '已取消暂缓(→推进中)', 'ok'); reloadAll();
}
}
function dlgAssign() {
openDlg('分配商机(待领取→推进中,E2)', fld('分配给 *', `<select name="userId">${userOpts()}</select>`),
[{ t: '分配', cls: 'ok', fn: async () => {
if (await tryApi('assign', () => api('POST', '/api/opportunity/assign', { form: { id: S.oppId, userId: v('userId') } })) != null) {
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 tryApi('pause', () => api('POST', '/api/opportunity/pause', { form })) != null) {
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 tryApi('release-pool', () => api('POST', '/api/opportunity/release-pool', { form })) != null) {
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 tryApi('close', () => api('POST', '/api/opportunity/close', { form })) != null) {
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 tryApi('reopen', () => api('POST', '/api/opportunity/reopen', { form })) != null) {
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 tryApi('handover', () => api('POST', '/api/opportunity/handover', { form })) != null) {
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 tryApi('switch-stage', () => api('POST', '/api/opportunity/stage/switch',
{ form: { oppId: S.oppId, toStageId: v('toStageId'), remark: v('remark') } })) != null) {
toast('阶段已切换', 'ok'); closeDlg(); reloadAll();
}
} }]);
}
async function doFocus() {
const d = S.detail || {};
const focused = d.followed === true;
const act = focused ? 'unfocus' : 'focus';
if (await tryApi(act, () => api('POST', `/api/opportunity/${act}`, { form: { oppId: S.oppId } })) != null) {
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 tryApi('edit', () => api('POST', '/api/opportunity/edit', { form })) != null) {
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 tryApi('follow/add', () => api('POST', '/api/opportunity/follow/add', { form })) != null) {
toast('跟进已写入(刷新回收锚点)', 'ok'); closeDlg(); loadSub('follow');
}
}
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 tryApi('survey/add', () => api('POST', '/api/opportunity/site-survey/add', { form })) != null) {
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 tryApi('card/submit', () => api('POST', '/api/opportunity/scheme-card/submit', { params: { id } })) != null) {
toast('方案卡已提交', 'ok'); loadSub('card');
}
}
/* ================= 规则配置 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 tryApi('publish', () => api('POST', `/api/rule/${fam}/publish`, { form: ruleDtoForm(fam, d) })) != null) {
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 tryApi(publish ? 'publish' : 'save-draft',
() => api('POST', `/api/rule/${fam}/${publish ? 'publish' : 'save-draft'}`, { form })) != null) {
toast(publish ? '已保存并发布' : '草稿已保存', 'ok'); closeDlg(); loadRuleList();
}
}
async function ruleCopy(id) {
if (await tryApi('copy', () => api('POST', `/api/rule/${S.family}/copy`, { params: { id } })) != null) {
toast('已复制为独立新规则(新编码 V1.0 草稿)', 'ok'); loadRuleList();
}
}
async function ruleDisable(id) {
if (await tryApi('disable', () => api('POST', `/api/rule/${S.family}/disable`, { params: { id } })) != null) {
toast('已停用', 'ok'); loadRuleList();
}
}
async function ruleDelete(id) {
if (await tryApi('delete', () => api('POST', `/api/rule/${S.family}/delete`, { params: { id } })) != null) {
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];
login();
})();
</script>
</body>
</html>