dashboard_template.html 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>五菱海外客户建联中台</title>
  7. <style>
  8. :root {
  9. color-scheme: light;
  10. --bg: #f3f6fa;
  11. --panel: #ffffff;
  12. --panel-soft: #f8fafc;
  13. --ink: #142033;
  14. --muted: #66758c;
  15. --line: #dfe6ef;
  16. --line-soft: #edf2f7;
  17. --blue: #1d5fd1;
  18. --blue-2: #2f80ed;
  19. --cyan: #13899b;
  20. --green: #16885a;
  21. --amber: #b7791f;
  22. --red: #c9443d;
  23. --navy: #0f2745;
  24. --shadow: 0 12px 30px rgba(20, 32, 52, .08);
  25. }
  26. * { box-sizing: border-box; }
  27. body {
  28. margin: 0;
  29. min-width: 320px;
  30. background: var(--bg);
  31. color: var(--ink);
  32. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
  33. line-height: 1.45;
  34. }
  35. .shell { max-width: 1440px; margin: 0 auto; padding: 24px 24px 44px; }
  36. .topbar {
  37. display: grid;
  38. grid-template-columns: minmax(0, 1fr) auto;
  39. align-items: end;
  40. gap: 18px;
  41. margin-bottom: 18px;
  42. }
  43. h1 { margin: 0 0 6px; font-size: 26px; line-height: 1.2; letter-spacing: 0; }
  44. .subtitle { color: var(--muted); font-size: 13px; overflow-wrap: anywhere; }
  45. .status-stack { display: flex; gap: 8px; align-items: center; justify-content: flex-end; flex-wrap: wrap; }
  46. .status-chip {
  47. display: inline-flex; align-items: center; gap: 6px; padding: 7px 10px;
  48. border: 1px solid var(--line); border-radius: 999px; background: rgba(255,255,255,.8);
  49. color: var(--muted); font-size: 12px; white-space: nowrap;
  50. }
  51. .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--green); }
  52. .grid { display: grid; gap: 14px; }
  53. .kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); margin-bottom: 14px; }
  54. .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; box-shadow: var(--shadow); }
  55. .kpi { padding: 15px 15px 13px; min-height: 132px; position: relative; overflow: hidden; }
  56. .kpi .label { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
  57. .kpi .value { font-size: 29px; font-weight: 760; letter-spacing: 0; }
  58. .kpi .note { color: var(--muted); font-size: 12px; margin-top: 5px; }
  59. .kpi .meter { height: 5px; margin-top: 14px; background: #edf2f7; border-radius: 999px; overflow: hidden; }
  60. .kpi .meter span { display: block; height: 100%; width: var(--w); background: var(--tone, var(--blue)); border-radius: 999px; }
  61. .main { grid-template-columns: 1.12fr .88fr; align-items: start; margin-bottom: 14px; }
  62. .panel { padding: 16px; }
  63. .panel-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 12px; }
  64. h2 { margin: 0; font-size: 16px; letter-spacing: 0; }
  65. .hint { color: var(--muted); font-size: 12px; }
  66. .funnel { display: grid; gap: 9px; }
  67. .funnel-row { display: grid; grid-template-columns: 116px minmax(0, 1fr) 86px; align-items: center; gap: 10px; }
  68. .funnel-name { color: var(--muted); font-size: 13px; }
  69. .funnel-track { height: 24px; background: #eef3f8; border-radius: 5px; overflow: hidden; }
  70. .funnel-fill { height: 100%; width: var(--w); background: linear-gradient(90deg, var(--blue), var(--cyan)); border-radius: 5px; }
  71. .funnel-count { text-align: right; font-weight: 700; font-variant-numeric: tabular-nums; }
  72. .charts { grid-template-columns: repeat(2, minmax(0, 1fr)); margin-bottom: 14px; }
  73. .bar-list { display: grid; gap: 10px; }
  74. .bar-row { display: grid; grid-template-columns: minmax(132px, .62fr) minmax(120px, 1fr) 70px; align-items: center; gap: 10px; }
  75. .bar-label { font-size: 13px; overflow-wrap: anywhere; }
  76. .bar-track { height: 10px; background: #edf2f7; border-radius: 999px; overflow: hidden; }
  77. .bar-fill { height: 100%; width: var(--w); background: var(--tone, var(--blue)); border-radius: 999px; }
  78. .bar-count { text-align: right; color: var(--muted); font-size: 12px; font-variant-numeric: tabular-nums; }
  79. .columns { height: 230px; display: flex; align-items: end; gap: 10px; padding-top: 8px; }
  80. .col-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 7px; }
  81. .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); }
  82. .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)); }
  83. .col-count { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
  84. .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; }
  85. .donut-wrap { display: grid; grid-template-columns: 160px minmax(0, 1fr); gap: 12px; align-items: center; }
  86. .donut { width: 150px; height: 150px; border-radius: 50%; background: conic-gradient(var(--blue) 0 100%); position: relative; margin: auto; }
  87. .donut::after { content: ""; position: absolute; inset: 24px; background: var(--panel); border-radius: 50%; box-shadow: inset 0 0 0 1px var(--line-soft); }
  88. .legend { display: grid; gap: 8px; }
  89. .legend-row { display: grid; grid-template-columns: 10px minmax(0, 1fr) auto; gap: 8px; align-items: center; color: var(--muted); font-size: 12px; }
  90. .legend-dot { width: 10px; height: 10px; border-radius: 3px; background: var(--tone); }
  91. .toolbar { display: grid; grid-template-columns: 1.25fr repeat(3, minmax(145px, .5fr)); gap: 10px; margin: 0 0 12px; }
  92. input, select {
  93. width: 100%; border: 1px solid var(--line); border-radius: 7px; background: #fff;
  94. color: var(--ink); padding: 9px 10px; font: inherit; font-size: 13px; outline: none;
  95. }
  96. input:focus, select:focus { border-color: rgba(29,95,209,.65); box-shadow: 0 0 0 3px rgba(29,95,209,.12); }
  97. .table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; }
  98. table { width: 100%; border-collapse: collapse; min-width: 1020px; font-size: 13px; }
  99. th, td { text-align: left; padding: 10px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: top; }
  100. th { position: sticky; top: 0; z-index: 1; background: #f7f9fc; color: var(--muted); font-weight: 650; }
  101. tbody tr:nth-child(even) { background: #fbfcfe; }
  102. tbody tr:hover { background: #f1f6ff; }
  103. .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; }
  104. .badge.green { background: #eaf7f1; color: #11734c; }
  105. .badge.amber { background: #fff6e6; color: #946200; }
  106. .badge.red { background: #fff0ef; color: #b12b24; }
  107. .muted { color: var(--muted); }
  108. .note { max-width: 420px; color: #39465a; }
  109. .wide { grid-column: 1 / -1; }
  110. .defs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; }
  111. .def { background: var(--panel-soft); border: 1px solid var(--line-soft); border-radius: 7px; padding: 10px; }
  112. .def strong { display: block; margin-bottom: 5px; }
  113. .def span { color: var(--muted); font-size: 12px; }
  114. @media (max-width: 1180px) {
  115. .kpis { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  116. .main, .charts { grid-template-columns: 1fr; }
  117. .defs { grid-template-columns: 1fr; }
  118. }
  119. @media (max-width: 720px) {
  120. .shell { padding: 18px 14px 32px; }
  121. .topbar { grid-template-columns: 1fr; }
  122. .status-stack { justify-content: flex-start; }
  123. .kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  124. .toolbar { grid-template-columns: 1fr; }
  125. .donut-wrap { grid-template-columns: 1fr; }
  126. }
  127. </style>
  128. </head>
  129. <body>
  130. <main class="shell">
  131. <header class="topbar">
  132. <div>
  133. <h1 id="title">五菱海外客户建联中台</h1>
  134. <div class="subtitle" id="subtitle"></div>
  135. </div>
  136. <div class="status-stack">
  137. <span class="status-chip"><span class="dot"></span>本地总表</span>
  138. <span class="status-chip" id="generatedAt"></span>
  139. <span class="status-chip" id="runId"></span>
  140. </div>
  141. </header>
  142. <section class="grid kpis" id="kpis"></section>
  143. <section class="grid main">
  144. <div class="card panel">
  145. <div class="panel-head"><h2>建联运营漏斗</h2><span class="hint">从客户池到已触达</span></div>
  146. <div class="funnel" id="funnel"></div>
  147. </div>
  148. <div class="card panel">
  149. <div class="panel-head"><h2>建联状态结构</h2><span class="hint">按当前状态聚合</span></div>
  150. <div class="donut-wrap"><div class="donut" id="statusDonut"></div><div class="legend" id="statusLegend"></div></div>
  151. </div>
  152. </section>
  153. <section class="grid charts">
  154. <div class="card panel"><div class="panel-head"><h2>客户属性分布</h2><span class="hint">横向条形图</span></div><div class="bar-list" id="attributeBars"></div></div>
  155. <div class="card panel"><div class="panel-head"><h2>客户来源分布</h2><span class="hint">纵向棒形图</span></div><div class="columns" id="sourceColumns"></div></div>
  156. <div class="card panel"><div class="panel-head"><h2>细分客户类型 Top</h2><span class="hint">优先看大客户池</span></div><div class="bar-list" id="typeBars"></div></div>
  157. <div class="card panel"><div class="panel-head"><h2>城市分布 Top</h2><span class="hint">区域资源密度</span></div><div class="bar-list" id="cityBars"></div></div>
  158. </section>
  159. <section class="grid charts">
  160. <div class="card panel"><div class="panel-head"><h2>Facebook 合作意向</h2><span class="hint">按最新有效回复的唯一客户统计</span></div><div class="bar-list" id="intentBars"></div></div>
  161. <div class="card panel"><div class="panel-head"><h2>最近 30 天有效回复</h2><span class="hint">按客户回复日期</span></div><div class="columns" id="replyTrendColumns"></div></div>
  162. </section>
  163. <section class="card panel wide" style="margin-bottom:14px">
  164. <div class="panel-head"><h2>高意向客户</h2><span class="hint" id="highIntentCount"></span></div>
  165. <div class="table-wrap">
  166. <table>
  167. <thead><tr><th>客户</th><th>合作意向</th><th>最新中文回复</th><th>判断依据</th><th>下一步建议</th><th>回复时间</th></tr></thead>
  168. <tbody id="highIntentRows"></tbody>
  169. </table>
  170. </div>
  171. </section>
  172. <section class="card panel wide">
  173. <div class="panel-head"><h2>待关注客户</h2><span class="hint" id="tableCount"></span></div>
  174. <div class="toolbar">
  175. <input id="searchInput" type="search" placeholder="搜索客户、城市、邮箱、备注">
  176. <select id="attributeFilter"><option value="">全部客户属性</option></select>
  177. <select id="statusFilter"><option value="">全部建联状态</option></select>
  178. <select id="emailFilter"><option value="">邮箱不限</option><option value="yes">有邮箱</option><option value="no">无邮箱</option></select>
  179. </div>
  180. <div class="table-wrap">
  181. <table>
  182. <thead><tr><th>总表行</th><th>客户</th><th>客户属性</th><th>客户类型</th><th>来源</th><th>联系方式</th><th>状态</th><th>备注摘要</th></tr></thead>
  183. <tbody id="customerRows"></tbody>
  184. </table>
  185. </div>
  186. </section>
  187. <section class="card panel wide" style="margin-top:14px">
  188. <div class="panel-head"><h2>指标口径</h2><span class="hint">保持每次生成口径一致</span></div>
  189. <div class="defs" id="definitions"></div>
  190. </section>
  191. </main>
  192. <script id="dashboardData" type="application/json">{{dashboard_json}}</script>
  193. <script>
  194. const data = JSON.parse(document.getElementById('dashboardData').textContent);
  195. const metrics = data.metrics;
  196. const charts = data.charts;
  197. const rows = data.dashboard_rows || data.attention_customers || [];
  198. const palette = ['#1d5fd1', '#16885a', '#b7791f', '#c9443d', '#13899b', '#6b7280', '#7c3aed', '#0f766e'];
  199. const pct = v => `${Number(v || 0).toFixed(1)}%`;
  200. const clampPct = v => Math.max(2, Math.min(100, Number(v || 0)));
  201. const safe = v => String(v ?? '').replace(/[&<>"]/g, s => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[s]));
  202. document.getElementById('title').textContent = data.title || '五菱海外客户建联中台';
  203. document.getElementById('subtitle').textContent = `数据来源:${data.workbook} / ${data.sheet_name}`;
  204. document.getElementById('generatedAt').textContent = `生成:${data.generated_at}`;
  205. document.getElementById('runId').textContent = `Run:${data.run_id}`;
  206. function renderKpis() {
  207. const cards = [
  208. ['有效客户', metrics.valid_customers, `原始 ${metrics.total_rows},剔除 ${metrics.excluded_customers}`, 100, '#1d5fd1'],
  209. ['可建联客户', metrics.contactable_customers, '有邮箱、电话或平台入口', metrics.valid_customers ? metrics.contactable_customers * 100 / metrics.valid_customers : 0, '#13899b'],
  210. ['建联率', pct(metrics.outreach_rate), `已建联 ${metrics.contacted_customers}`, metrics.outreach_rate, '#16885a'],
  211. ['邮箱覆盖率', pct(metrics.email_coverage_rate), `有邮箱 ${metrics.email_customers}`, metrics.email_coverage_rate, '#1d5fd1'],
  212. ['退信率', pct(metrics.bounce_rate), `退信/拒收 ${metrics.bounced_customers}`, metrics.bounce_rate, '#c9443d'],
  213. ['待人工复核', metrics.needs_review_customers, `${pct(metrics.review_rate)} 的有效客户`, metrics.review_rate, '#b7791f'],
  214. ['Facebook 回复率', pct(metrics.facebook_reply_rate), `有效回复 ${metrics.facebook_reply_customers} / 已私信 ${metrics.facebook_dm_customers}`, metrics.facebook_reply_rate, '#13899b'],
  215. ['Facebook 高意向', Number(metrics.facebook_clear_intent_customers || 0) + Number(metrics.facebook_potential_intent_customers || 0), `明确 ${metrics.facebook_clear_intent_customers || 0},潜在 ${metrics.facebook_potential_intent_customers || 0}`, metrics.facebook_reply_customers ? (Number(metrics.facebook_clear_intent_customers || 0) + Number(metrics.facebook_potential_intent_customers || 0)) * 100 / metrics.facebook_reply_customers : 0, '#16885a']
  216. ];
  217. 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('');
  218. }
  219. function renderFunnel() {
  220. const items = [['总表客户', metrics.total_rows], ['有效客户', metrics.valid_customers], ['可建联', metrics.contactable_customers], ['已建联', metrics.contacted_customers], ['Facebook有效回复', metrics.facebook_reply_customers], ['待复核', metrics.needs_review_customers]];
  221. const max = Math.max(...items.map(i => Number(i[1] || 0)), 1);
  222. 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('');
  223. }
  224. function renderBars(targetId, items, tone = '#1d5fd1') {
  225. const max = Math.max(...(items || []).map(i => Number(i.count || 0)), 1);
  226. 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>';
  227. }
  228. function renderColumns(targetId, sourceItems) {
  229. const items = (sourceItems || []).slice(-10);
  230. const max = Math.max(...items.map(i => Number(i.count || 0)), 1);
  231. document.getElementById(targetId).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>';
  232. }
  233. function renderDonut() {
  234. const items = (charts.status || []).slice(0, 6);
  235. const total = items.reduce((sum, item) => sum + Number(item.count || 0), 0) || 1;
  236. let acc = 0;
  237. const stops = items.map((item, idx) => {
  238. const start = acc; acc += Number(item.count || 0) / total * 100;
  239. return `${palette[idx % palette.length]} ${start}% ${acc}%`;
  240. });
  241. document.getElementById('statusDonut').style.background = `conic-gradient(${stops.join(', ')})`;
  242. 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('');
  243. }
  244. function optionList(values) { return [...new Set(values.filter(Boolean))].sort((a, b) => a.localeCompare(b, 'zh-Hans')); }
  245. function initFilters() {
  246. const attr = document.getElementById('attributeFilter');
  247. const status = document.getElementById('statusFilter');
  248. attr.innerHTML += optionList(rows.map(r => r.attribute)).map(v => `<option value="${safe(v)}">${safe(v)}</option>`).join('');
  249. status.innerHTML += optionList(rows.map(r => r.status || '未填写')).map(v => `<option value="${safe(v)}">${safe(v)}</option>`).join('');
  250. ['searchInput', 'attributeFilter', 'statusFilter', 'emailFilter'].forEach(id => document.getElementById(id).addEventListener('input', renderRows));
  251. }
  252. function renderRows() {
  253. const q = document.getElementById('searchInput').value.trim().toLowerCase();
  254. const attr = document.getElementById('attributeFilter').value;
  255. const status = document.getElementById('statusFilter').value;
  256. const emailMode = document.getElementById('emailFilter').value;
  257. const filtered = rows.filter(row => {
  258. const hay = [row.company, row.city, row.attribute, row.type, row.contact, row.status, row.note, row.source].join(' ').toLowerCase();
  259. if (q && !hay.includes(q)) return false;
  260. if (attr && row.attribute !== attr) return false;
  261. if (status && (row.status || '未填写') !== status) return false;
  262. if (emailMode === 'yes' && !row.has_email) return false;
  263. if (emailMode === 'no' && row.has_email) return false;
  264. return true;
  265. });
  266. document.getElementById('tableCount').textContent = `显示 ${filtered.length} / ${rows.length}`;
  267. document.getElementById('customerRows').innerHTML = filtered.slice(0, 80).map(row => {
  268. const statusClass = row.bounced ? 'red' : row.contacted ? 'green' : row.review ? 'amber' : '';
  269. 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>`;
  270. }).join('') || '<tr><td colspan="8" class="muted">没有匹配客户</td></tr>';
  271. }
  272. function renderHighIntent() {
  273. const items = data.facebook_high_intent_customers || [];
  274. document.getElementById('highIntentCount').textContent = `共 ${items.length} 个`;
  275. document.getElementById('highIntentRows').innerHTML = items.map(item => `<tr><td><strong>${safe(item.company)}</strong></td><td><span class="badge green">${safe(item.intent)}</span></td><td class="note">${safe(item.latest_reply)}</td><td class="note">${safe(item.reason)}</td><td class="note">${safe(item.next_action)}</td><td>${safe(item.message_time)}</td></tr>`).join('') || '<tr><td colspan="6" class="muted">暂无明确或潜在意向客户</td></tr>';
  276. }
  277. function renderDefinitions() {
  278. document.getElementById('definitions').innerHTML = Object.entries(data.definitions || {}).map(([key, value]) => `<div class="def"><strong>${safe(key)}</strong><span>${safe(value)}</span></div>`).join('');
  279. }
  280. renderKpis(); renderFunnel(); renderBars('attributeBars', charts.attribute || [], '#16885a'); renderBars('typeBars', charts.customer_type || [], '#1d5fd1'); renderBars('cityBars', charts.city || [], '#13899b'); renderBars('intentBars', charts.facebook_intent || [], '#16885a'); renderColumns('sourceColumns', charts.source || []); renderColumns('replyTrendColumns', charts.facebook_reply_trend || []); renderDonut(); renderHighIntent(); renderDefinitions(); initFilters(); renderRows();
  281. </script>
  282. </body>
  283. </html>