添加 static/index.html
This commit is contained in:
658
static/index.html
Normal file
658
static/index.html
Normal file
@@ -0,0 +1,658 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>🌌 恶意客户智能匹配平台</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CDN -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* 小动画与样式 */
|
||||||
|
.rotate { transition: transform .22s ease; display:inline-block; }
|
||||||
|
.rotate.open { transform: rotate(90deg); }
|
||||||
|
.fade-up { transform: translateY(6px); opacity: 0; transition: all .28s ease; }
|
||||||
|
.fade-up.in { transform: translateY(0); opacity: 1; }
|
||||||
|
.glass { background: linear-gradient(180deg, rgba(255,255,255,0.75), rgba(255,255,255,0.62)); backdrop-filter: blur(6px); }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar { height:8px; width:8px; }
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb { background: rgba(0,0,0,0.12); border-radius: 8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gradient-to-br from-slate-50 via-blue-50 to-purple-100 font-sans">
|
||||||
|
|
||||||
|
<div class="max-w-6xl mx-auto p-6">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-extrabold text-gray-900 flex items-center gap-3">
|
||||||
|
<span>🚀 恶意客户智能匹配平台</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-600 mt-1">上传订单 Excel,选择客户库进行快速匹配 — 地址 → 客户 → 订单 三层视图,支持 CRUD 管理。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<div class="w-2 h-2 bg-blue-400 rounded-full"></div><span>分析</span>
|
||||||
|
<div class="w-2 h-2 bg-red-400 rounded-full ml-2"></div><span>管理</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Tabs -->
|
||||||
|
<nav class="mb-6 bg-transparent">
|
||||||
|
<div class="inline-flex rounded-xl bg-white/70 glass border border-gray-200 p-1">
|
||||||
|
<button id="tabAnalyze" class="tab-btn px-4 py-2 rounded-lg font-medium text-sm text-gray-800 bg-gradient-to-r from-blue-50 to-purple-50 shadow-sm">📦 订单分析</button>
|
||||||
|
<button id="tabManage" class="tab-btn px-4 py-2 rounded-lg font-medium text-sm text-gray-600 hover:text-gray-800">🧠 恶意客户管理</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main>
|
||||||
|
<!-- ========== 分析面板 ========== -->
|
||||||
|
<section id="panelAnalyze" class="fade-up in">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-12 gap-6">
|
||||||
|
<!-- Left: 控件 -->
|
||||||
|
<div class="md:col-span-4 bg-white glass rounded-2xl p-5 shadow">
|
||||||
|
<h2 class="text-lg font-semibold mb-3">上传 & 分析</h2>
|
||||||
|
|
||||||
|
<label class="text-sm text-gray-600">订单文件 (.xlsx)</label>
|
||||||
|
<input id="fileInput" type="file" accept=".xlsx" class="mt-2 w-full border rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-300" />
|
||||||
|
|
||||||
|
<label class="text-sm text-gray-600 mt-4 block">选择恶意客户库</label>
|
||||||
|
<select id="suspectSelect" class="mt-2 w-full border rounded-lg p-2"></select>
|
||||||
|
|
||||||
|
<div class="mt-4 flex gap-2">
|
||||||
|
<button id="analyzeBtn" class="flex-1 bg-gradient-to-r from-blue-500 to-purple-500 text-white font-semibold px-4 py-2 rounded-lg shadow hover:scale-[1.02] transition">⚡ 发起分析</button>
|
||||||
|
<button id="refreshBtn" title="刷新客户库" class="px-4 py-2 rounded-lg border bg-white">⟳</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="statusText" class="mt-3 text-sm text-gray-500">状态:未开始</p>
|
||||||
|
|
||||||
|
<!-- 浮动统计(小版)-->
|
||||||
|
<div id="statsMini" class="mt-4 bg-white rounded-lg p-3 border flex flex-col gap-2">
|
||||||
|
<div class="flex justify-between text-sm text-gray-600"><span>地址:</span><strong id="miniAddr">0</strong></div>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600"><span>订单:</span><strong id="miniOrders">0</strong></div>
|
||||||
|
<div class="flex justify-between text-sm text-gray-600"><span>恶意客户:</span><strong id="miniSuspects">0</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 说明 -->
|
||||||
|
<div class="mt-4 text-xs text-gray-500">
|
||||||
|
地址层默认展开;点击客户可展开订单列表。支持查看 note / info_url / registertime。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: 结果 -->
|
||||||
|
<div class="md:col-span-8 space-y-4">
|
||||||
|
<!-- 搜索 / filters -->
|
||||||
|
<div class="flex gap-3 items-center">
|
||||||
|
<input id="globalSearch" placeholder="🔎 搜索:姓名 / 电话 / 地址 / 订单号" class="flex-1 p-2 rounded-lg border" />
|
||||||
|
<div class="text-sm text-gray-500">显示:<span id="displayCount">0</span> 条匹配</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 结果容器 -->
|
||||||
|
<div id="resultsContainer" class="space-y-4"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========== 管理面板 ========== -->
|
||||||
|
<section id="panelManage" class="hidden">
|
||||||
|
<div class="bg-white glass rounded-2xl p-5 shadow">
|
||||||
|
<div class="flex gap-4 md:gap-8 items-start">
|
||||||
|
<div class="w-full md:w-1/3">
|
||||||
|
<h3 class="font-semibold text-lg">管理客户库</h3>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="text-sm text-gray-600">选择库文件</label>
|
||||||
|
<div class="flex gap-2 mt-2">
|
||||||
|
<select id="manageSelect" class="flex-1 border rounded-lg p-2"></select>
|
||||||
|
<button id="createFileBtn" class="px-3 py-2 rounded-lg bg-green-50 border border-green-200">新建</button>
|
||||||
|
</div>
|
||||||
|
<input id="newFileName" placeholder="文件名(不带 .json)" class="mt-2 p-2 border rounded-lg w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<label class="text-sm text-gray-600">搜索记录</label>
|
||||||
|
<input id="manageSearch" placeholder="按姓名/手机号/地址搜索" class="mt-2 p-2 border rounded-lg w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<button id="addRecordBtn" class="w-full bg-blue-600 text-white py-2 rounded-lg shadow">➕ 添加记录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-lg">记录列表</h3>
|
||||||
|
<div id="recordsList" class="mt-3 space-y-2 max-h-[60vh] overflow-auto p-2 scrollbar-thin"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 全局浮动统计框(左下) -->
|
||||||
|
<div id="statsBox" class="fixed bottom-6 left-6 bg-white/95 glass rounded-xl shadow-lg p-4 w-64 hidden">
|
||||||
|
<div class="text-sm text-gray-600">分析统计</div>
|
||||||
|
<div class="mt-2">
|
||||||
|
<div class="flex justify-between text-sm"><span>地址数:</span><strong id="statAddress">0</strong></div>
|
||||||
|
<div class="flex justify-between text-sm"><span>订单数:</span><strong id="statOrders">0</strong></div>
|
||||||
|
<div class="flex justify-between text-sm"><span>恶意客户数:</span><strong id="statSuspects">0</strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 弹窗:添加/编辑记录 -->
|
||||||
|
<div id="recordModal" class="fixed inset-0 bg-black/40 hidden items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-2xl w-full max-w-2xl p-5 shadow-lg">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h4 id="recordModalTitle" class="font-semibold">添加记录</h4>
|
||||||
|
<button id="closeRecordModal" class="text-gray-500">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
<input id="f_name" placeholder="姓名" class="p-2 border rounded" />
|
||||||
|
<input id="f_phone" placeholder="手机号" class="p-2 border rounded" />
|
||||||
|
<input id="f_address" placeholder="地址(省 市 区)" class="p-2 border rounded col-span-1 md:col-span-2" />
|
||||||
|
<input id="f_store" placeholder="店铺" class="p-2 border rounded" />
|
||||||
|
<input id="f_order_id" placeholder="关联订单号(允许多个,逗号分隔;系统会保留后4位)" class="p-2 border rounded" />
|
||||||
|
<input id="f_info_url" placeholder="信息链接(info_url)" class="p-2 border rounded" />
|
||||||
|
<textarea id="f_note" placeholder="备注 / note" class="p-2 border rounded col-span-1 md:col-span-2"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-end gap-2">
|
||||||
|
<button id="cancelRecord" class="px-4 py-2 rounded border">取消</button>
|
||||||
|
<button id="saveRecord" class="px-4 py-2 rounded bg-green-600 text-white">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 简易确认模态 -->
|
||||||
|
<div id="confirmModal" class="fixed inset-0 bg-black/40 hidden items-center justify-center p-4">
|
||||||
|
<div class="bg-white rounded-xl p-4 shadow">
|
||||||
|
<div id="confirmText" class="text-sm text-gray-700"></div>
|
||||||
|
<div class="mt-3 flex justify-end gap-2">
|
||||||
|
<button id="confirmNo" class="px-3 py-1 rounded border">取消</button>
|
||||||
|
<button id="confirmYes" class="px-3 py-1 rounded bg-red-600 text-white">确认</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ---------- DOM ----------
|
||||||
|
const tabAnalyze = document.getElementById('tabAnalyze');
|
||||||
|
const tabManage = document.getElementById('tabManage');
|
||||||
|
const panelAnalyze = document.getElementById('panelAnalyze');
|
||||||
|
const panelManage = document.getElementById('panelManage');
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
const suspectSelect = document.getElementById('suspectSelect');
|
||||||
|
const analyzeBtn = document.getElementById('analyzeBtn');
|
||||||
|
const refreshBtn = document.getElementById('refreshBtn');
|
||||||
|
const statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
const resultsContainer = document.getElementById('resultsContainer');
|
||||||
|
const globalSearch = document.getElementById('globalSearch');
|
||||||
|
const displayCount = document.getElementById('displayCount');
|
||||||
|
|
||||||
|
const statsBox = document.getElementById('statsBox');
|
||||||
|
const statAddress = document.getElementById('statAddress');
|
||||||
|
const statOrders = document.getElementById('statOrders');
|
||||||
|
const statSuspects = document.getElementById('statSuspects');
|
||||||
|
|
||||||
|
const miniAddr = document.getElementById('miniAddr');
|
||||||
|
const miniOrders = document.getElementById('miniOrders');
|
||||||
|
const miniSuspects = document.getElementById('miniSuspects');
|
||||||
|
|
||||||
|
// 管理面板 DOM
|
||||||
|
const manageSelect = document.getElementById('manageSelect');
|
||||||
|
const createFileBtn = document.getElementById('createFileBtn');
|
||||||
|
const newFileName = document.getElementById('newFileName');
|
||||||
|
const manageSearch = document.getElementById('manageSearch');
|
||||||
|
const addRecordBtn = document.getElementById('addRecordBtn');
|
||||||
|
const recordsList = document.getElementById('recordsList');
|
||||||
|
|
||||||
|
// record modal
|
||||||
|
const recordModal = document.getElementById('recordModal');
|
||||||
|
const recordModalTitle = document.getElementById('recordModalTitle');
|
||||||
|
const closeRecordModal = document.getElementById('closeRecordModal');
|
||||||
|
const cancelRecord = document.getElementById('cancelRecord');
|
||||||
|
const saveRecord = document.getElementById('saveRecord');
|
||||||
|
|
||||||
|
const f_name = document.getElementById('f_name');
|
||||||
|
const f_phone = document.getElementById('f_phone');
|
||||||
|
const f_address = document.getElementById('f_address');
|
||||||
|
const f_store = document.getElementById('f_store');
|
||||||
|
const f_order_id = document.getElementById('f_order_id');
|
||||||
|
const f_info_url = document.getElementById('f_info_url');
|
||||||
|
const f_note = document.getElementById('f_note');
|
||||||
|
|
||||||
|
// confirm modal
|
||||||
|
const confirmModal = document.getElementById('confirmModal');
|
||||||
|
const confirmText = document.getElementById('confirmText');
|
||||||
|
const confirmYes = document.getElementById('confirmYes');
|
||||||
|
const confirmNo = document.getElementById('confirmNo');
|
||||||
|
|
||||||
|
// state
|
||||||
|
let currentSuspectFile = null;
|
||||||
|
let currentRecords = [];
|
||||||
|
let editingIndex = -1;
|
||||||
|
let latestResults = { fullMatches: [], cityMatches: [], totalOrders: 0 };
|
||||||
|
|
||||||
|
// ---------- Utils ----------
|
||||||
|
function show(el){ el.classList.remove('hidden'); el.style.display='flex'; }
|
||||||
|
function hide(el){ el.classList.add('hidden'); el.style.display='none'; }
|
||||||
|
|
||||||
|
async function fetchJson(url, opts={}) {
|
||||||
|
const r = await fetch(url, opts);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Tabs ----------
|
||||||
|
tabAnalyze.addEventListener('click', () => {
|
||||||
|
panelAnalyze.classList.remove('hidden');
|
||||||
|
panelManage.classList.add('hidden');
|
||||||
|
tabAnalyze.classList.remove('text-gray-600');
|
||||||
|
tabManage.classList.remove('bg-gradient-to-r','from-blue-50','to-purple-50');
|
||||||
|
tabAnalyze.classList.add('bg-gradient-to-r','from-blue-50','to-purple-50');
|
||||||
|
});
|
||||||
|
tabManage.addEventListener('click', () => {
|
||||||
|
panelManage.classList.remove('hidden');
|
||||||
|
panelAnalyze.classList.add('hidden');
|
||||||
|
tabAnalyze.classList.remove('bg-gradient-to-r','from-blue-50','to-purple-50');
|
||||||
|
tabManage.classList.add('bg-gradient-to-r','from-blue-50','to-purple-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Load suspect files ----------
|
||||||
|
async function loadSuspectOptions(){
|
||||||
|
try{
|
||||||
|
const res = await fetchJson('/api/suspects');
|
||||||
|
const files = res.files || [];
|
||||||
|
suspectSelect.innerHTML = files.map(f => `<option value="${f}">${f}</option>`).join('');
|
||||||
|
manageSelect.innerHTML = files.map(f => `<option value="${f}">${f}</option>`).join('');
|
||||||
|
if(files.length){
|
||||||
|
suspectSelect.value = files[0];
|
||||||
|
manageSelect.value = files[0];
|
||||||
|
currentSuspectFile = files[0];
|
||||||
|
} else {
|
||||||
|
currentSuspectFile = null;
|
||||||
|
}
|
||||||
|
} catch(err){
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshBtn.addEventListener('click', loadSuspectOptions);
|
||||||
|
|
||||||
|
// ---------- Analyze ----------
|
||||||
|
analyzeBtn.addEventListener('click', async () => {
|
||||||
|
if(!fileInput.files.length){ alert('请选择 .xlsx 文件'); return; }
|
||||||
|
if(!suspectSelect.value){ alert('请选择恶意客户库'); return; }
|
||||||
|
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('file', fileInput.files[0]);
|
||||||
|
form.append('suspectFile', suspectSelect.value);
|
||||||
|
|
||||||
|
statusText.textContent = '分析中...';
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
resultsContainer.innerHTML = '';
|
||||||
|
|
||||||
|
try{
|
||||||
|
const r = await fetch('/api/analyze', { method: 'POST', body: form });
|
||||||
|
const data = await r.json();
|
||||||
|
if(data.error){ statusText.textContent = '错误:' + data.error; analyzeBtn.disabled=false; return; }
|
||||||
|
latestResults = data;
|
||||||
|
statusText.textContent = `分析完成:共 ${data.totalOrders} 条订单`;
|
||||||
|
renderResults(data);
|
||||||
|
} catch(err){
|
||||||
|
console.error(err);
|
||||||
|
statusText.textContent = '网络或服务异常';
|
||||||
|
} finally {
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Render Results (三层结构) ----------
|
||||||
|
function renderResults(data){
|
||||||
|
resultsContainer.innerHTML = '';
|
||||||
|
const full = data.fullMatches || [];
|
||||||
|
const city = data.cityMatches || [];
|
||||||
|
let totalCustomers = 0;
|
||||||
|
let totalOrders = data.totalOrders || 0;
|
||||||
|
let totalAddresses = full.length;
|
||||||
|
|
||||||
|
// helper: create address/city section (address default expanded)
|
||||||
|
function createTopSection(title, groups, isCity=false){
|
||||||
|
if(!groups || groups.length===0) return;
|
||||||
|
|
||||||
|
const section = document.createElement('section');
|
||||||
|
section.className = 'bg-white rounded-2xl p-4 shadow-lg';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'flex items-center justify-between cursor-pointer';
|
||||||
|
header.innerHTML = `<div class="text-lg font-semibold">${isCity ? '🏙️ 城市级匹配:' : '📍 地址匹配:'} ${title}</div>
|
||||||
|
<div class="text-sm text-gray-500">(点击收起/展开) <span class="ml-2 rotate">▶</span></div>`;
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.className = 'mt-3 space-y-3';
|
||||||
|
|
||||||
|
// 默认展开地址层:content visible; rotate open
|
||||||
|
content.style.display = '';
|
||||||
|
header.querySelector('.rotate').classList.add('open');
|
||||||
|
|
||||||
|
header.addEventListener('click', () => {
|
||||||
|
if(content.style.display === 'none'){ content.style.display=''; header.querySelector('.rotate').classList.add('open'); }
|
||||||
|
else { content.style.display='none'; header.querySelector('.rotate').classList.remove('open'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// groups: array of { address|city, customers:[...] }
|
||||||
|
groups.forEach(g => {
|
||||||
|
// each g has .address or .city and .customers
|
||||||
|
const areaTitle = isCity ? g.city : g.address;
|
||||||
|
const areaBlock = document.createElement('div');
|
||||||
|
areaBlock.className = 'p-3 bg-gray-50 border border-gray-100 rounded-lg';
|
||||||
|
|
||||||
|
const areaHeader = document.createElement('div');
|
||||||
|
areaHeader.className = 'text-sm font-medium text-gray-700 mb-2';
|
||||||
|
areaHeader.textContent = areaTitle;
|
||||||
|
|
||||||
|
const customersWrap = document.createElement('div');
|
||||||
|
customersWrap.className = 'space-y-2';
|
||||||
|
|
||||||
|
g.customers.forEach(c => {
|
||||||
|
totalCustomers++;
|
||||||
|
const custCard = document.createElement('div');
|
||||||
|
custCard.className = 'bg-white rounded-lg border p-3 flex flex-col gap-2 shadow-sm';
|
||||||
|
|
||||||
|
// customer header (click to toggle orders)
|
||||||
|
const custHeader = document.createElement('div');
|
||||||
|
custHeader.className = 'flex items-center justify-between cursor-pointer';
|
||||||
|
|
||||||
|
const left = document.createElement('div');
|
||||||
|
left.innerHTML = `<div class="text-red-600 font-semibold">${c.name || '—'} <span class="text-xs text-gray-500">(${c.phone||'—'})</span></div>
|
||||||
|
<div class="text-xs text-gray-500">${c.address || ''}</div>`;
|
||||||
|
|
||||||
|
const right = document.createElement('div');
|
||||||
|
right.innerHTML = `<div class="text-xs text-gray-500">注册:${c.registertime || '-'}</div>
|
||||||
|
<div class="text-xs text-gray-500">${(c.order_id||[]).length ? ('订单:' + (Array.isArray(c.order_id)? c.order_id.join(',') : c.order_id)) : ''}</div>`;
|
||||||
|
|
||||||
|
const arrow = document.createElement('span');
|
||||||
|
arrow.className = 'rotate text-gray-500';
|
||||||
|
arrow.textContent = '▶';
|
||||||
|
right.appendChild(arrow);
|
||||||
|
|
||||||
|
custHeader.appendChild(left);
|
||||||
|
custHeader.appendChild(right);
|
||||||
|
|
||||||
|
const details = document.createElement('div');
|
||||||
|
details.className = 'mt-2 ml-2 border-l border-gray-100 pl-3 hidden';
|
||||||
|
|
||||||
|
// note / link
|
||||||
|
const noteP = document.createElement('div');
|
||||||
|
noteP.className = 'text-sm text-gray-600 mb-2';
|
||||||
|
noteP.innerHTML = `📝 ${c.note || '-'} <a href="${c.info_url||'#'}" target="_blank" class="text-blue-500 underline text-xs ml-2">🔗 详情</a>`;
|
||||||
|
|
||||||
|
// orders list
|
||||||
|
const ordersWrap = document.createElement('div');
|
||||||
|
ordersWrap.className = 'grid gap-2';
|
||||||
|
|
||||||
|
(c.matched_orders || []).forEach(o => {
|
||||||
|
const orderEl = document.createElement('div');
|
||||||
|
orderEl.className = 'flex items-center justify-between bg-blue-50 border border-blue-100 rounded p-2 text-sm';
|
||||||
|
orderEl.innerHTML = `<div>📦 <strong>${o.orderId}</strong> <span class="text-gray-600 text-xs">(${o.orderName||''}/${o.orderPhone||''})</span></div>
|
||||||
|
<div class="text-blue-600 text-sm">${o.score} 分 <span class="text-xs text-gray-500">(${o.percentage}%)</span></div>`;
|
||||||
|
ordersWrap.appendChild(orderEl);
|
||||||
|
});
|
||||||
|
|
||||||
|
details.appendChild(noteP);
|
||||||
|
details.appendChild(ordersWrap);
|
||||||
|
|
||||||
|
// default: customer level COLLAPSED (only address expanded as requested)
|
||||||
|
custHeader.addEventListener('click', () => {
|
||||||
|
if(details.classList.contains('hidden')) {
|
||||||
|
details.classList.remove('hidden');
|
||||||
|
arrow.classList.add('open');
|
||||||
|
} else {
|
||||||
|
details.classList.add('hidden');
|
||||||
|
arrow.classList.remove('open');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
custCard.appendChild(custHeader);
|
||||||
|
custCard.appendChild(details);
|
||||||
|
customersWrap.appendChild(custCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
areaBlock.appendChild(areaHeader);
|
||||||
|
areaBlock.appendChild(customersWrap);
|
||||||
|
content.appendChild(areaBlock);
|
||||||
|
});
|
||||||
|
|
||||||
|
section.appendChild(header);
|
||||||
|
section.appendChild(content);
|
||||||
|
resultsContainer.appendChild(section);
|
||||||
|
}
|
||||||
|
|
||||||
|
// render both
|
||||||
|
if(full && full.length){
|
||||||
|
createTopSection('全部地址(默认展开)', full, false);
|
||||||
|
}
|
||||||
|
if(city && city.length){
|
||||||
|
createTopSection('城市汇总', city, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// stats
|
||||||
|
statAddress.textContent = (data.fullMatches || []).length;
|
||||||
|
statOrders.textContent = data.totalOrders || 0;
|
||||||
|
statSuspects.textContent = totalCustomers;
|
||||||
|
miniAddr.textContent = statAddress.textContent;
|
||||||
|
miniOrders.textContent = statOrders.textContent;
|
||||||
|
miniSuspects.textContent = statSuspects.textContent;
|
||||||
|
|
||||||
|
// show stats box
|
||||||
|
statsBox.classList.remove('hidden');
|
||||||
|
|
||||||
|
// display count
|
||||||
|
displayCount.textContent = (totalCustomers + (data.fullMatches||[]).length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter (client-side)
|
||||||
|
globalSearch.addEventListener('input', (e) => {
|
||||||
|
const q = e.target.value.trim().toLowerCase();
|
||||||
|
if(!q){
|
||||||
|
// re-render last results
|
||||||
|
if(latestResults && latestResults.totalOrders) renderResults(latestResults);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter by scanning latestResults and keep only nodes that match
|
||||||
|
const filtered = { fullMatches: [], cityMatches: [], totalOrders: latestResults.totalOrders||0 };
|
||||||
|
|
||||||
|
function filterGroups(groups){
|
||||||
|
const out = [];
|
||||||
|
(groups||[]).forEach(g => {
|
||||||
|
const customers = (g.customers||[]).map(c => {
|
||||||
|
const matched_orders = (c.matched_orders||[]).filter(o => {
|
||||||
|
return (''+o.orderId).toLowerCase().includes(q) ||
|
||||||
|
(''+o.orderName).toLowerCase().includes(q) ||
|
||||||
|
(''+o.orderPhone).toLowerCase().includes(q);
|
||||||
|
});
|
||||||
|
const custMatch = (''+c.name).toLowerCase().includes(q) ||
|
||||||
|
(''+c.phone).toLowerCase().includes(q) ||
|
||||||
|
(''+c.address).toLowerCase().includes(q) ||
|
||||||
|
(''+(c.order_id||[]).join(',')).toLowerCase().includes(q);
|
||||||
|
// include customer if customer fields match OR some matched_orders match
|
||||||
|
if(custMatch || matched_orders.length) {
|
||||||
|
// clone with filtered orders
|
||||||
|
const clone = Object.assign({}, c);
|
||||||
|
clone.matched_orders = matched_orders.length ? matched_orders : c.matched_orders || [];
|
||||||
|
return clone;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
if(customers.length) out.push(Object.assign({}, g, { customers }));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.fullMatches = filterGroups(latestResults.fullMatches);
|
||||||
|
filtered.cityMatches = filterGroups(latestResults.cityMatches);
|
||||||
|
renderResults(filtered);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- Management (CRUD) ----------
|
||||||
|
async function loadRecordsForSelected(){
|
||||||
|
const fname = manageSelect.value;
|
||||||
|
if(!fname) { recordsList.innerHTML = '<div class="text-sm text-gray-500">无文件</div>'; return; }
|
||||||
|
currentSuspectFile = fname;
|
||||||
|
try{
|
||||||
|
const res = await fetchJson('/api/suspects/' + encodeURIComponent(fname));
|
||||||
|
if(res.error){ recordsList.innerHTML = '<div class="text-sm text-red-600">加载失败</div>'; return; }
|
||||||
|
currentRecords = res.data || [];
|
||||||
|
renderRecordsList(currentRecords);
|
||||||
|
} catch(err){
|
||||||
|
console.error(err);
|
||||||
|
recordsList.innerHTML = '<div class="text-sm text-red-600">加载失败</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecordsList(list){
|
||||||
|
const q = (manageSearch.value||'').trim().toLowerCase();
|
||||||
|
recordsList.innerHTML = '';
|
||||||
|
if(!list.length) recordsList.innerHTML = '<div class="text-sm text-gray-500">空记录</div>';
|
||||||
|
list.forEach((r, idx) => {
|
||||||
|
const text = `${r.name||''} ${r.phone||''} ${r.address||''}`.toLowerCase();
|
||||||
|
if(q && text.indexOf(q) === -1) return;
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'p-3 rounded-lg border flex items-start justify-between gap-3 bg-white';
|
||||||
|
row.innerHTML = `<div>
|
||||||
|
<div class="font-medium">${r.name || '—'} <span class="text-xs text-gray-500">(${r.phone||''})</span></div>
|
||||||
|
<div class="text-xs text-gray-600">${r.address || ''}</div>
|
||||||
|
<div class="text-xs text-gray-400 mt-1">注册:${r.registertime || '-'}</div>
|
||||||
|
<div class="text-xs text-gray-500 mt-1">订单:${r.order_id || ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 items-end">
|
||||||
|
<button class="editBtn text-indigo-600 text-sm">编辑</button>
|
||||||
|
<button class="delBtn text-red-600 text-sm">删除</button>
|
||||||
|
</div>`;
|
||||||
|
// handlers
|
||||||
|
row.querySelector('.editBtn').addEventListener('click', () => openEditRecord(idx));
|
||||||
|
row.querySelector('.delBtn').addEventListener('click', () => confirmDelete(idx));
|
||||||
|
recordsList.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
manageSelect.addEventListener('change', loadRecordsForSelected);
|
||||||
|
manageSearch.addEventListener('input', () => renderRecordsList(currentRecords));
|
||||||
|
|
||||||
|
// create library file
|
||||||
|
createFileBtn.addEventListener('click', async () => {
|
||||||
|
const name = (newFileName.value||'').trim();
|
||||||
|
if(!name) { alert('请输入文件名'); return; }
|
||||||
|
try{
|
||||||
|
const res = await fetchJson('/api/suspects/' + encodeURIComponent(name), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({ data: [] })
|
||||||
|
});
|
||||||
|
if(res.error) throw new Error(res.error);
|
||||||
|
await loadSuspectOptions();
|
||||||
|
alert('创建成功');
|
||||||
|
} catch(err){
|
||||||
|
alert('创建失败:' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// add / edit record
|
||||||
|
addRecordBtn.addEventListener('click', openAddRecord);
|
||||||
|
closeRecordModal.addEventListener('click', ()=> hide(recordModal));
|
||||||
|
cancelRecord.addEventListener('click', ()=> hide(recordModal));
|
||||||
|
|
||||||
|
function openAddRecord(){
|
||||||
|
editingIndex = -1;
|
||||||
|
recordModalTitle.textContent = '添加记录';
|
||||||
|
f_name.value=''; f_phone.value=''; f_address.value=''; f_store.value=''; f_order_id.value=''; f_info_url.value=''; f_note.value='';
|
||||||
|
show(recordModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditRecord(idx){
|
||||||
|
editingIndex = idx;
|
||||||
|
const r = currentRecords[idx];
|
||||||
|
if(!r) return;
|
||||||
|
recordModalTitle.textContent = '编辑记录';
|
||||||
|
f_name.value = r.name || '';
|
||||||
|
f_phone.value = r.phone || '';
|
||||||
|
f_address.value = r.address || '';
|
||||||
|
f_store.value = r.store || '';
|
||||||
|
f_order_id.value = r.order_id || '';
|
||||||
|
f_info_url.value = r.info_url || '';
|
||||||
|
f_note.value = r.note || '';
|
||||||
|
show(recordModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
saveRecord.addEventListener('click', async () => {
|
||||||
|
if(!currentSuspectFile){ alert('请先选择或创建客户库'); return; }
|
||||||
|
const payload = {
|
||||||
|
name: (f_name.value||'').trim(),
|
||||||
|
phone: (f_phone.value||'').trim(),
|
||||||
|
address: (f_address.value||'').trim(),
|
||||||
|
store: (f_store.value||'').trim(),
|
||||||
|
order_id: (f_order_id.value||'').trim(),
|
||||||
|
note: (f_note.value||'').trim(),
|
||||||
|
info_url: (f_info_url.value||'').trim()
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if(editingIndex === -1){
|
||||||
|
const res = await fetchJson('/api/suspects/' + encodeURIComponent(currentSuspectFile) + '/records', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if(res.error) throw new Error(res.error);
|
||||||
|
alert('添加成功');
|
||||||
|
} else {
|
||||||
|
const res = await fetchJson('/api/suspects/' + encodeURIComponent(currentSuspectFile) + '/records/' + editingIndex, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if(res.error) throw new Error(res.error);
|
||||||
|
alert('更新成功');
|
||||||
|
}
|
||||||
|
hide(recordModal);
|
||||||
|
await loadRecordsForSelected();
|
||||||
|
} catch(err){
|
||||||
|
alert('保存失败:' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete record with confirm
|
||||||
|
function confirmDelete(idx){
|
||||||
|
confirmText.textContent = '确认删除该记录?该操作不可恢复。';
|
||||||
|
show(confirmModal);
|
||||||
|
confirmYes.onclick = async () => {
|
||||||
|
try{
|
||||||
|
const res = await fetchJson('/api/suspects/' + encodeURIComponent(currentSuspectFile) + '/records/' + idx, { method: 'DELETE' });
|
||||||
|
if(res.error) throw new Error(res.error);
|
||||||
|
hide(confirmModal);
|
||||||
|
await loadRecordsForSelected();
|
||||||
|
} catch(err){
|
||||||
|
alert('删除失败:' + err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
confirmNo.onclick = () => hide(confirmModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Init ----------
|
||||||
|
(async function init(){
|
||||||
|
await loadSuspectOptions();
|
||||||
|
// set default to analyze tab
|
||||||
|
tabAnalyze.click();
|
||||||
|
// set manage panel default data
|
||||||
|
await loadRecordsForSelected();
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user