| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>五菱海外客户建联中台</title>
- <style>
- :root {
- color-scheme: light;
- --bg: #f3f6fa;
- --panel: #ffffff;
- --panel-soft: #f8fafc;
- --ink: #142033;
- --muted: #66758c;
- --line: #dfe6ef;
- --line-soft: #edf2f7;
- --blue: #1d5fd1;
- --blue-2: #2f80ed;
- --cyan: #13899b;
- --green: #16885a;
- --amber: #b7791f;
- --red: #c9443d;
- --navy: #0f2745;
- --shadow: 0 12px 30px rgba(20, 32, 52, .08);
- }
- * { box-sizing: border-box; }
- body {
- margin: 0;
- min-width: 320px;
- background: var(--bg);
- color: var(--ink);
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
- line-height: 1.45;
- }
- .shell { max-width: 1440px; margin: 0 auto; padding: 24px 24px 44px; }
- .topbar {
- display: grid;
- grid-template-columns: minmax(0, 1fr) auto;
- align-items: end;
- gap: 18px;
- margin-bottom: 18px;
- }
- h1 { margin: 0 0 6px; font-size: 26px; line-height: 1.2; letter-spacing: 0; }
- .subtitle { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
- .status-stack { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
- .status-chip {
- display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px;
- border: 1px solid var(--line); border-radius: 999px; background: rgba(255,255,255,.8);
- color: var(--muted); font-size: 12px; white-space: nowrap;
- }
- .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--green); }
- .grid { display: grid; gap: 14px; }
- .kpis { grid-template-columns: repeat(6, minmax(0, 1fr)); margin-bottom: 14px; }
- .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); }
- .kpi { padding: 15px 15px 13px; min-height: 132px; position: relative; overflow: hidden; }
- .kpi .label { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
- .kpi .value { font-size: 29px; font-weight: 760; letter-spacing: 0; }
- .kpi .note { color: var(--muted); font-size: 12px; margin-top: 5px; }
- .kpi .meter { height: 5px; margin-top: 14px; background: #edf2f7; border-radius: 999px; overflow: hidden; }
- .kpi .meter span { display: block; height: 100%; width: var(--w); background: var(--tone, var(--blue)); border-radius: 999px; }
- .main { grid-template-columns: 1.12fr .88fr; align-items: start; margin-bottom: 14px; }
- .panel { padding: 16px; }
- .panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 12px; }
- h2 { margin: 0; font-size: 16px; letter-spacing: 0; }
- .hint { color: var(--muted); font-size: 12px; }
- .funnel { display: grid; gap: 9px; }
- .funnel-row { display: grid; grid-template-columns: 116px minmax(0, 1fr) 86px; align-items: center; gap: 10px; }
- .funnel-name { color: var(--muted); font-size: 13px; }
- .funnel-track { height: 24px; background: #eef3f8; border-radius: 5px; overflow: hidden; }
- .funnel-fill { height: 100%; width: var(--w); background: linear-gradient(90deg, var(--blue), var(--cyan)); border-radius: 5px; }
- .funnel-count { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
- .charts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 14px; }
- .bar-list { display: grid; gap: 10px; }
- .bar-row { display: grid; grid-template-columns: minmax(132px, .62fr) minmax(120px, 1fr) 70px; align-items: center; gap: 10px; }
- .bar-label { font-size: 13px; overflow-wrap: anywhere; }
- .bar-track { height: 10px; background: #edf2f7; border-radius: 999px; overflow: hidden; }
- .bar-fill { height: 100%; width: var(--w); background: var(--tone, var(--blue)); border-radius: 999px; }
- .bar-count { text-align: right; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
- .columns { height: 230px; display: flex; align-items: end; gap: 10px; padding-top: 8px; }
- .col-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 7px; }
- .col-track { width: 100%; height: 160px; display: flex; align-items: end; justify-content: center; background: linear-gradient(180deg, transparent, #f6f8fb); border-bottom: 1px solid var(--line); }
- .col { width: 56%; min-width: 18px; max-width: 42px; height: var(--h); border-radius: 5px 5px 0 0; background: linear-gradient(180deg, var(--blue-2), var(--blue)); }
- .col-count { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
- .col-label { max-width: 100%; min-height: 34px; color: var(--muted); font-size: 11px; text-align: center; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
- .donut-wrap { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 12px; align-items: center; }
- .donut { width: 150px; height: 150px; border-radius: 50%; background: conic-gradient(var(--blue) 0 100%); position: relative; margin: auto; }
- .donut::after { content: ""; position: absolute; inset: 24px; background: var(--panel); border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line-soft); }
- .legend { display: grid; gap: 8px; }
- .legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }
- .legend-dot { width: 10px; height: 10px; border-radius: 3px; background: var(--tone); }
- .toolbar { display: grid; grid-template-columns: 1.25fr repeat(3, minmax(145px, .5fr)); gap: 10px; margin: 0 0 12px; }
- input, select {
- width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #fff;
- color: var(--ink); padding: 9px 10px; font: inherit; font-size: 13px; outline: none;
- }
- input:focus, select:focus { border-color: rgba(29,95,209,.65); box-shadow: 0 0 0 3px rgba(29,95,209,.12); }
- .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
- table { width: 100%; border-collapse: collapse; min-width: 1020px; font-size: 13px; }
- th, td { text-align: left; padding: 10px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
- th { position: sticky; top: 0; z-index: 1; background: #f7f9fc; color: var(--muted); font-weight: 650; }
- tbody tr:nth-child(even) { background: #fbfcfe; }
- tbody tr:hover { background: #f1f6ff; }
- .badge { display: inline-flex; align-items: center; max-width: 100%; padding: 3px 8px; border-radius: 999px; background: #eef4ff; color: #174ea6; font-size: 12px; white-space: nowrap; }
- .badge.green { background: #eaf7f1; color: #11734c; }
- .badge.amber { background: #fff6e6; color: #946200; }
- .badge.red { background: #fff0ef; color: #b12b24; }
- .muted { color: var(--muted); }
- .note { max-width: 420px; color: #39465a; }
- .wide { grid-column: 1 / -1; }
- .defs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
- .def { background: var(--panel-soft); border: 1px solid var(--line-soft); border-radius: 7px; padding: 10px; }
- .def strong { display: block; margin-bottom: 5px; }
- .def span { color: var(--muted); font-size: 12px; }
- @media (max-width: 1180px) {
- .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
- .main, .charts { grid-template-columns: 1fr; }
- .defs { grid-template-columns: 1fr; }
- }
- @media (max-width: 720px) {
- .shell { padding: 18px 14px 32px; }
- .topbar { grid-template-columns: 1fr; }
- .status-stack { justify-content: flex-start; }
- .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
- .toolbar { grid-template-columns: 1fr; }
- .donut-wrap { grid-template-columns: 1fr; }
- }
- </style>
- </head>
- <body>
- <main class="shell">
- <header class="topbar">
- <div>
- <h1 id="title">五菱海外客户建联中台</h1>
- <div class="subtitle" id="subtitle"></div>
- </div>
- <div class="status-stack">
- <span class="status-chip"><span class="dot"></span>本地总表</span>
- <span class="status-chip" id="generatedAt"></span>
- <span class="status-chip" id="runId"></span>
- </div>
- </header>
- <section class="grid kpis" id="kpis"></section>
- <section class="grid main">
- <div class="card panel">
- <div class="panel-head"><h2>建联运营漏斗</h2><span class="hint">从客户池到已触达</span></div>
- <div class="funnel" id="funnel"></div>
- </div>
- <div class="card panel">
- <div class="panel-head"><h2>建联状态结构</h2><span class="hint">按当前状态聚合</span></div>
- <div class="donut-wrap"><div class="donut" id="statusDonut"></div><div class="legend" id="statusLegend"></div></div>
- </div>
- </section>
- <section class="grid charts">
- <div class="card panel"><div class="panel-head"><h2>客户属性分布</h2><span class="hint">横向条形图</span></div><div class="bar-list" id="attributeBars"></div></div>
- <div class="card panel"><div class="panel-head"><h2>客户来源分布</h2><span class="hint">纵向棒形图</span></div><div class="columns" id="sourceColumns"></div></div>
- <div class="card panel"><div class="panel-head"><h2>细分客户类型 Top</h2><span class="hint">优先看大客户池</span></div><div class="bar-list" id="typeBars"></div></div>
- <div class="card panel"><div class="panel-head"><h2>城市分布 Top</h2><span class="hint">区域资源密度</span></div><div class="bar-list" id="cityBars"></div></div>
- </section>
- <section class="card panel wide">
- <div class="panel-head"><h2>待关注客户</h2><span class="hint" id="tableCount"></span></div>
- <div class="toolbar">
- <input id="searchInput" type="search" placeholder="搜索客户、城市、邮箱、备注">
- <select id="attributeFilter"><option value="">全部客户属性</option></select>
- <select id="statusFilter"><option value="">全部建联状态</option></select>
- <select id="emailFilter"><option value="">邮箱不限</option><option value="yes">有邮箱</option><option value="no">无邮箱</option></select>
- </div>
- <div class="table-wrap">
- <table>
- <thead><tr><th>总表行</th><th>客户</th><th>客户属性</th><th>客户类型</th><th>来源</th><th>联系方式</th><th>状态</th><th>备注摘要</th></tr></thead>
- <tbody id="customerRows"></tbody>
- </table>
- </div>
- </section>
- <section class="card panel wide" style="margin-top:14px">
- <div class="panel-head"><h2>指标口径</h2><span class="hint">保持每次生成口径一致</span></div>
- <div class="defs" id="definitions"></div>
- </section>
- </main>
- <script id="dashboardData" type="application/json">{{dashboard_json}}</script>
- <script>
- const data = JSON.parse(document.getElementById('dashboardData').textContent);
- const metrics = data.metrics;
- const charts = data.charts;
- const rows = data.dashboard_rows || data.attention_customers || [];
- const palette = ['#1d5fd1', '#16885a', '#b7791f', '#c9443d', '#13899b', '#6b7280', '#7c3aed', '#0f766e'];
- const pct = v => `${Number(v || 0).toFixed(1)}%`;
- const clampPct = v => Math.max(2, Math.min(100, Number(v || 0)));
- const safe = v => String(v ?? '').replace(/[&<>"]/g, s => ({'&':'&','<':'<','>':'>','"':'"'}[s]));
- document.getElementById('title').textContent = data.title || '五菱海外客户建联中台';
- document.getElementById('subtitle').textContent = `数据来源:${data.workbook} / ${data.sheet_name}`;
- document.getElementById('generatedAt').textContent = `生成:${data.generated_at}`;
- document.getElementById('runId').textContent = `Run:${data.run_id}`;
- function renderKpis() {
- const cards = [
- ['有效客户', metrics.valid_customers, `原始 ${metrics.total_rows},剔除 ${metrics.excluded_customers}`, 100, '#1d5fd1'],
- ['可建联客户', metrics.contactable_customers, '有邮箱、电话或平台入口', metrics.valid_customers ? metrics.contactable_customers * 100 / metrics.valid_customers : 0, '#13899b'],
- ['建联率', pct(metrics.outreach_rate), `已建联 ${metrics.contacted_customers}`, metrics.outreach_rate, '#16885a'],
- ['邮箱覆盖率', pct(metrics.email_coverage_rate), `有邮箱 ${metrics.email_customers}`, metrics.email_coverage_rate, '#1d5fd1'],
- ['退信率', pct(metrics.bounce_rate), `退信/拒收 ${metrics.bounced_customers}`, metrics.bounce_rate, '#c9443d'],
- ['待人工复核', metrics.needs_review_customers, `${pct(metrics.review_rate)} 的有效客户`, metrics.review_rate, '#b7791f']
- ];
- document.getElementById('kpis').innerHTML = cards.map(([label, value, note, width, tone]) => `<article class="card kpi"><div class="label">${safe(label)}</div><div class="value">${safe(value)}</div><div class="note">${safe(note)}</div><div class="meter"><span style="--w:${clampPct(width)}%;--tone:${tone}"></span></div></article>`).join('');
- }
- function renderFunnel() {
- const items = [['总表客户', metrics.total_rows], ['有效客户', metrics.valid_customers], ['可建联', metrics.contactable_customers], ['已建联', metrics.contacted_customers], ['待复核', metrics.needs_review_customers]];
- const max = Math.max(...items.map(i => Number(i[1] || 0)), 1);
- document.getElementById('funnel').innerHTML = items.map(([name, count]) => `<div class="funnel-row"><div class="funnel-name">${safe(name)}</div><div class="funnel-track"><div class="funnel-fill" style="--w:${clampPct(count * 100 / max)}%"></div></div><div class="funnel-count">${safe(count)}</div></div>`).join('');
- }
- function renderBars(targetId, items, tone = '#1d5fd1') {
- const max = Math.max(...(items || []).map(i => Number(i.count || 0)), 1);
- document.getElementById(targetId).innerHTML = (items || []).map((item, idx) => `<div class="bar-row"><div class="bar-label">${safe(item.name || '未填写')}</div><div class="bar-track"><div class="bar-fill" style="--w:${clampPct(item.count * 100 / max)}%;--tone:${palette[idx % palette.length] || tone}"></div></div><div class="bar-count">${safe(item.count)} / ${safe(item.rate)}%</div></div>`).join('') || '<div class="muted">暂无数据</div>';
- }
- function renderColumns() {
- const items = (charts.source || []).slice(0, 8);
- const max = Math.max(...items.map(i => Number(i.count || 0)), 1);
- document.getElementById('sourceColumns').innerHTML = items.map((item, idx) => `<div class="col-wrap"><div class="col-count">${safe(item.count)}</div><div class="col-track"><div class="col" style="--h:${clampPct(item.count * 100 / max)}%;background:linear-gradient(180deg, ${palette[idx % palette.length]}, #0f2745)"></div></div><div class="col-label" title="${safe(item.name)}">${safe(item.name)}</div></div>`).join('') || '<div class="muted">暂无数据</div>';
- }
- function renderDonut() {
- const items = (charts.status || []).slice(0, 6);
- const total = items.reduce((sum, item) => sum + Number(item.count || 0), 0) || 1;
- let acc = 0;
- const stops = items.map((item, idx) => {
- const start = acc; acc += Number(item.count || 0) / total * 100;
- return `${palette[idx % palette.length]} ${start}% ${acc}%`;
- });
- document.getElementById('statusDonut').style.background = `conic-gradient(${stops.join(', ')})`;
- document.getElementById('statusLegend').innerHTML = items.map((item, idx) => `<div class="legend-row"><span class="legend-dot" style="--tone:${palette[idx % palette.length]}"></span><span>${safe(item.name)}</span><strong>${safe(item.count)}</strong></div>`).join('');
- }
- function optionList(values) { return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-Hans')); }
- function initFilters() {
- const attr = document.getElementById('attributeFilter');
- const status = document.getElementById('statusFilter');
- attr.innerHTML += optionList(rows.map(r => r.attribute)).map(v => `<option value="${safe(v)}">${safe(v)}</option>`).join('');
- status.innerHTML += optionList(rows.map(r => r.status || '未填写')).map(v => `<option value="${safe(v)}">${safe(v)}</option>`).join('');
- ['searchInput', 'attributeFilter', 'statusFilter', 'emailFilter'].forEach(id => document.getElementById(id).addEventListener('input', renderRows));
- }
- function renderRows() {
- const q = document.getElementById('searchInput').value.trim().toLowerCase();
- const attr = document.getElementById('attributeFilter').value;
- const status = document.getElementById('statusFilter').value;
- const emailMode = document.getElementById('emailFilter').value;
- const filtered = rows.filter(row => {
- const hay = [row.company, row.city, row.attribute, row.type, row.contact, row.status, row.note, row.source].join(' ').toLowerCase();
- if (q && !hay.includes(q)) return false;
- if (attr && row.attribute !== attr) return false;
- if (status && (row.status || '未填写') !== status) return false;
- if (emailMode === 'yes' && !row.has_email) return false;
- if (emailMode === 'no' && row.has_email) return false;
- return true;
- });
- document.getElementById('tableCount').textContent = `显示 ${filtered.length} / ${rows.length}`;
- document.getElementById('customerRows').innerHTML = filtered.slice(0, 80).map(row => {
- const statusClass = row.bounced ? 'red' : row.contacted ? 'green' : row.review ? 'amber' : '';
- return `<tr><td>${safe(row.row)}</td><td><strong>${safe(row.company)}</strong><div class="muted">${safe(row.city || '')}</div></td><td><span class="badge">${safe(row.attribute || '未填写')}</span></td><td>${safe(row.type || '未填写')}</td><td>${safe(row.source || '未标明')}</td><td>${safe(row.contact || '无')}</td><td><span class="badge ${statusClass}">${safe(row.status || '未填写')}</span></td><td class="note">${safe(row.note || '')}</td></tr>`;
- }).join('') || '<tr><td colspan="8" class="muted">没有匹配客户</td></tr>';
- }
- function renderDefinitions() {
- document.getElementById('definitions').innerHTML = Object.entries(data.definitions || {}).map(([key, value]) => `<div class="def"><strong>${safe(key)}</strong><span>${safe(value)}</span></div>`).join('');
- }
- renderKpis(); renderFunnel(); renderBars('attributeBars', charts.attribute || [], '#16885a'); renderBars('typeBars', charts.customer_type || [], '#1d5fd1'); renderBars('cityBars', charts.city || [], '#13899b'); renderColumns(); renderDonut(); renderDefinitions(); initFilters(); renderRows();
- </script>
- </body>
- </html>
|