140 lines
14 KiB
JavaScript
140 lines
14 KiB
JavaScript
// 部门匹配独立规则库,不对业务数据执行回写。
|
||
var departmentMatchingState = {rows: [], kind: 'platform', page: 1, search: ''};
|
||
var departmentMatchingKinds = {platform: '平台层规则', brand: '品牌层规则', nonbrand: '非品牌清单', shop: '店铺归属对照表'};
|
||
var departmentMatchingFields = {
|
||
platform: [['department', '归属部门(必填)'], ['shop_keywords', '店铺关键词(逗号分隔)'], ['source_keywords', '空店铺来源(逗号分隔,精确匹配)']],
|
||
brand: [['keyword', '品牌关键词(必填)'], ['department', '默认归属'], ['mode', '匹配方式(必填)'], ['pdd_department', '拼多多归属'], ['overseas_department', '京东海外/国际归属'], ['notes', '判定说明']],
|
||
nonbrand: [['name', '名称(必填)'], ['reason', '为什么不是品牌'], ['guidance', '判定说明']],
|
||
shop: [['shop', '店铺(必填)'], ['department', '预期归属(必填)'], ['layer', '参考命中层级']]
|
||
};
|
||
var departmentMatchingModes = {fixed: '固定归属(全平台)', split: '按平台分配归属', pdd_only: '拼多多专属,其他回落平台层'};
|
||
|
||
async function departmentMatchingRequest(path, data) {
|
||
var response = await fetch('/api/department-matching/' + path, data === undefined ? {} : {
|
||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(data)
|
||
});
|
||
var result = await response.json();
|
||
if (!response.ok || result.code !== 0) throw new Error(result.msg || '请求失败');
|
||
return result;
|
||
}
|
||
|
||
async function loadDepartmentMatchingPage(container) {
|
||
container.innerHTML = `<style>
|
||
.dm-table{border-collapse:collapse;width:100%;font-size:13px}
|
||
.dm-table th,.dm-table td{border:1px solid var(--border,#e5e7eb);padding:10px 12px;text-align:left;vertical-align:top;overflow-wrap:anywhere;max-width:360px}
|
||
.dm-table th{background:var(--bg-secondary,#f5f6f8);position:sticky;top:0;z-index:1;white-space:nowrap}
|
||
.dm-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:12px 0}
|
||
.dm-panel{padding:18px;margin-bottom:16px}.dm-help{color:var(--text-light,#6b7280);font-size:12px;line-height:1.8}
|
||
.dm-scroll{overflow:auto;max-height:calc(100vh - 470px);min-height:200px}
|
||
.dm-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px}
|
||
.dm-fields label{display:block;font-size:13px;margin-bottom:6px}.dm-fields .form-input{width:100%;box-sizing:border-box}
|
||
@media(max-width:700px){.dm-fields{grid-template-columns:1fr}}
|
||
</style><div class="page-header"><h1>部门匹配管理</h1></div>
|
||
<div class="card dm-panel"><strong>匹配测试</strong><div class="dm-help">品牌层优先 → 平台关键词按具体程度匹配 → 店铺为空时按来源精确兜底。仅试算归属,不修改业务数据。</div>
|
||
<div class="dm-toolbar"><input id="dmShop" class="form-input" style="width:360px;max-width:100%" placeholder="店铺名称" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
|
||
<input id="dmSource" class="form-input" style="width:230px;max-width:100%" placeholder="数据来源(店铺为空时使用)" onkeydown="if(event.key==='Enter')testDepartmentMatching()">
|
||
<button class="btn btn-primary" onclick="testDepartmentMatching()">测试匹配</button><button class="btn" onclick="checkDepartmentMatching()">核验店铺对照表</button></div>
|
||
<div id="dmResult" role="status" class="dm-help">请输入店铺或数据来源进行测试。</div></div>
|
||
<div class="card dm-panel"><div id="dmTabs" class="dm-toolbar"></div>
|
||
<div class="dm-toolbar"><input id="dmSearch" class="form-input" style="width:300px" placeholder="搜索关键词、归属、店铺或说明" onkeydown="if(event.key==='Enter')searchDepartmentMatching(false)">
|
||
<button class="btn btn-primary" onclick="searchDepartmentMatching(false)">搜索</button><button class="btn" onclick="searchDepartmentMatching(true)">重置</button>
|
||
<button id="dmCreate" class="btn btn-primary" style="margin-left:auto;display:none" onclick="editDepartmentMatching(0)">新增</button></div>
|
||
<div class="dm-help" style="margin-bottom:10px">非品牌清单用于解释排除项;归属通过平台规则判定。平台层先比较关键词长度,长度相同时优先级数字越小越优先。</div>
|
||
<div id="dmTable" class="dm-scroll">正在加载…</div><div id="dmPager" class="dm-toolbar" style="justify-content:center"></div></div>`;
|
||
try {
|
||
var result = await departmentMatchingRequest('records');
|
||
departmentMatchingState.rows = result.data;
|
||
renderDepartmentMatching();
|
||
} catch (err) { document.getElementById('dmTable').textContent = err.message; }
|
||
}
|
||
|
||
function renderDepartmentMatching() {
|
||
var state = departmentMatchingState;
|
||
var tabs = document.getElementById('dmTabs');
|
||
if (!tabs) return;
|
||
tabs.innerHTML = Object.keys(departmentMatchingKinds).map(function(kind) {
|
||
return '<button class="btn ' + (state.kind === kind ? 'btn-primary' : '') + '" onclick="selectDepartmentMatchingTab(\'' + kind + '\')">' + departmentMatchingKinds[kind] + '(' + state.rows.filter(function(r){return r.kind === kind;}).length + ')</button>';
|
||
}).join('');
|
||
document.getElementById('dmCreate').style.display = window._hasPagePerm('department_matching', 'create') ? '' : 'none';
|
||
document.getElementById('dmSearch').value = state.search;
|
||
var rows = state.rows.filter(function(row) {
|
||
return row.kind === state.kind && (!state.search || departmentMatchingFields[row.kind].map(function(f){return row.payload[f[0]] || '';}).join(' ').toLowerCase().includes(state.search.toLowerCase()));
|
||
});
|
||
var pages = Math.max(1, Math.ceil(rows.length / 20));
|
||
state.page = Math.min(Math.max(state.page, 1), pages);
|
||
var fields = departmentMatchingFields[state.kind];
|
||
var canEdit = window._hasPagePerm('department_matching', 'edit');
|
||
var canDelete = window._hasPagePerm('department_matching', 'delete');
|
||
var rule = state.kind === 'platform' || state.kind === 'brand';
|
||
var showPriority = state.kind === 'platform';
|
||
var heads = '<th>序号</th>' + fields.map(function(f){return '<th>' + escHtml(f[1].replace('(必填)','')) + '</th>';}).join('') + (showPriority ? '<th>优先级</th>' : '') + (rule ? '<th>状态</th>' : '') + (canEdit || canDelete ? '<th>操作</th>' : '');
|
||
var body = rows.slice((state.page - 1) * 20, state.page * 20).map(function(row, i) {
|
||
return '<tr><td>' + ((state.page - 1) * 20 + i + 1) + '</td>' + fields.map(function(f) {
|
||
var value = row.payload[f[0]] || '';
|
||
if (f[0] === 'mode') value = departmentMatchingModes[value] || value;
|
||
return '<td>' + escHtml(String(value)) + '</td>';
|
||
}).join('') + (showPriority ? '<td>' + row.priority + '</td>' : '') + (rule ? '<td>' + (row.enabled ? '启用' : '停用') + '</td>' : '') + (canEdit || canDelete ? '<td style="white-space:nowrap">' +
|
||
(canEdit ? '<button class="btn btn-sm" onclick="editDepartmentMatching(' + row.id + ')">编辑</button> ' : '') +
|
||
(canDelete ? '<button class="btn btn-sm" style="color:#dc2626" onclick="deleteDepartmentMatching(' + row.id + ')">删除</button>' : '') + '</td>' : '') + '</tr>';
|
||
}).join('');
|
||
document.getElementById('dmTable').innerHTML = '<table class="dm-table"><thead><tr>' + heads + '</tr></thead><tbody>' + (body || '<tr><td colspan="15">暂无记录</td></tr>') + '</tbody></table>';
|
||
document.getElementById('dmPager').innerHTML = '<span>共 ' + rows.length + ' 条,每页20条</span><button class="btn" onclick="pageDepartmentMatching(-1)" ' + (state.page === 1 ? 'disabled' : '') + '>上一页</button><span>' + state.page + ' / ' + pages + '</span><button class="btn" onclick="pageDepartmentMatching(1)" ' + (state.page === pages ? 'disabled' : '') + '>下一页</button><input id="dmJump" class="form-input" type="number" min="1" max="' + pages + '" style="width:70px" value="' + state.page + '" onkeydown="if(event.key===\'Enter\')pageDepartmentMatching(0)"><button class="btn" onclick="pageDepartmentMatching(0)">跳转</button>';
|
||
}
|
||
|
||
function selectDepartmentMatchingTab(kind) { departmentMatchingState.kind=kind; departmentMatchingState.page=1; renderDepartmentMatching(); }
|
||
function searchDepartmentMatching(reset) { departmentMatchingState.search=reset?'':document.getElementById('dmSearch').value.trim(); departmentMatchingState.page=1; renderDepartmentMatching(); }
|
||
function pageDepartmentMatching(delta) {
|
||
departmentMatchingState.page = delta ? departmentMatchingState.page + delta : (parseInt(document.getElementById('dmJump').value,10) || 1);
|
||
renderDepartmentMatching();
|
||
}
|
||
|
||
function editDepartmentMatching(id) {
|
||
var row = departmentMatchingState.rows.find(function(r){return r.id === id;}) || {id:0, kind:departmentMatchingState.kind, payload:{}, priority:100, enabled:1};
|
||
document.getElementById('dmEditModal')?.remove();
|
||
var modal = document.createElement('div'); modal.id='dmEditModal'; modal.className='modal-overlay'; modal.style.cssText='display:flex;z-index:15000';
|
||
var fields = departmentMatchingFields[row.kind].map(function(f) {
|
||
var value = row.payload[f[0]] || '';
|
||
var control;
|
||
if (f[0] === 'mode') control = '<select class="form-input" data-dm-field="mode">' + Object.keys(departmentMatchingModes).map(function(mode) {return '<option value="' + mode + '" ' + (value === mode ? 'selected' : '') + '>' + departmentMatchingModes[mode] + '</option>';}).join('') + '</select>';
|
||
else control = '<textarea class="form-input" rows="' + (['notes','guidance','reason'].includes(f[0]) ? 3 : 2) + '" maxlength="4000" data-dm-field="' + f[0] + '">' + escHtml(String(value)) + '</textarea>';
|
||
return '<div><label>' + escHtml(f[1]) + '</label>' + control + '</div>';
|
||
}).join('');
|
||
var rule = row.kind === 'brand' || row.kind === 'platform';
|
||
modal.innerHTML = '<div class="modal" style="width:min(760px,94vw);max-height:90vh;display:flex;flex-direction:column"><div class="modal-header"><h3>' + (id ? '编辑' : '新增') + departmentMatchingKinds[row.kind] + '</h3><button class="modal-close" onclick="document.getElementById(\'dmEditModal\').remove()">×</button></div><form id="dmEditForm" style="display:flex;flex-direction:column;min-height:0"><div class="modal-body" style="overflow:auto"><div class="dm-fields">' + fields + (rule ? '<div><label>优先级(0–100000,数字越小越优先)</label><input id="dmPriority" type="number" min="0" max="100000" required class="form-input" value="' + row.priority + '"></div><div><label>状态</label><select id="dmEnabled" class="form-input"><option value="1">启用</option><option value="0" ' + (!row.enabled ? 'selected' : '') + '>停用</option></select></div>' : '') + '</div><div class="dm-help" style="margin-top:12px">品牌关键词取自店铺名称。按平台分配时,拼多多优先使用拼多多归属,京东海外/国际其次,其他使用默认归属。非品牌条目用于说明,不独立覆盖归属。</div><div id="dmEditError" style="color:#dc2626;margin-top:8px" role="alert"></div></div><div class="modal-footer"><button type="button" class="btn" onclick="document.getElementById(\'dmEditModal\').remove()">取消</button><button id="dmSave" type="submit" class="btn btn-primary">保存</button></div></form></div>';
|
||
if (row.kind === 'brand') modal.querySelector('#dmPriority').parentElement.remove();
|
||
document.body.appendChild(modal);
|
||
document.getElementById('dmEditForm').onsubmit = async function(event) {
|
||
event.preventDefault();
|
||
var payload=Object.assign({},row.payload); modal.querySelectorAll('[data-dm-field]').forEach(function(el){payload[el.dataset.dmField]=el.value;});
|
||
var button=document.getElementById('dmSave'); button.disabled=true;
|
||
try {
|
||
await departmentMatchingRequest('save', {id:row.id, kind:row.kind, payload:payload, priority:row.kind==='platform'?Number(document.getElementById('dmPriority').value):row.priority, enabled:rule?Number(document.getElementById('dmEnabled').value):1});
|
||
departmentMatchingState.rows=(await departmentMatchingRequest('records')).data;
|
||
modal.remove(); renderDepartmentMatching();
|
||
} catch(err) { document.getElementById('dmEditError').textContent=err.message; button.disabled=false; }
|
||
};
|
||
}
|
||
|
||
async function deleteDepartmentMatching(id) {
|
||
if (!confirm('确认删除此记录?删除规则会影响后续匹配测试结果。')) return;
|
||
try { await departmentMatchingRequest('delete',{id:id}); departmentMatchingState.rows=(await departmentMatchingRequest('records')).data; renderDepartmentMatching(); }
|
||
catch(err) { alert(err.message); }
|
||
}
|
||
|
||
async function testDepartmentMatching() {
|
||
var el=document.getElementById('dmResult');
|
||
try {
|
||
var data=(await departmentMatchingRequest('test',{shop:document.getElementById('dmShop').value, source:document.getElementById('dmSource').value})).data;
|
||
el.textContent='归属:' + (data.department || '未匹配') + ' | ' + data.layer + ' | ' + data.reason;
|
||
} catch(err) { el.textContent=err.message; }
|
||
}
|
||
|
||
async function checkDepartmentMatching() {
|
||
var el=document.getElementById('dmResult'); el.textContent='正在核验…';
|
||
try {
|
||
var data=(await departmentMatchingRequest('check',{})).data;
|
||
el.textContent='核验完成:' + data.passed + ' / ' + data.total + ' 条归属一致。';
|
||
if (data.mismatches.length) el.innerHTML += '<div style="overflow:auto;max-height:240px"><table class="dm-table"><thead><tr><th>店铺</th><th>预期归属</th><th>实际归属</th><th>原因</th></tr></thead><tbody>' + data.mismatches.map(function(r){return '<tr><td>' + escHtml(r.shop) + '</td><td>' + escHtml(r.expected) + '</td><td>' + escHtml(r.actual || '未匹配') + '</td><td>' + escHtml(r.reason) + '</td></tr>';}).join('') + '</tbody></table></div>';
|
||
} catch(err) { el.textContent=err.message; }
|
||
}
|