| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663 |
- # -*- coding: utf-8 -*-
- """
- 门店数据工作台生成器 —— 把月度检测数据渲染成单文件交互式 HTML 工作台
- 输入: 门店数据_<月份>.csv (与 detect.py 同源)
- 输出: 04_输出/门店工作台_<月份>.html
- v3 变更:
- - 右侧菜单栏「考核指标」:展示全部阈值默认值,可自定义修改
- - 阈值改动后前端按 detect.py 同一套规则重算全量门店(KPI/图表/表格/详情同步)
- - 自定义阈值存 localStorage,刷新不丢;可一键恢复官方默认
- - 新增「导出清单」:导出当前筛选结果 CSV
- 用法: python build_dashboard.py [--month 2026-10]
- """
- import csv
- import json
- import os
- import sys
- from detect import detect, THRESH
- BASE = os.path.dirname(os.path.abspath(__file__))
- OUT = os.path.join(os.path.dirname(BASE), '04_输出')
- TEMPLATE = r"""<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>门店数据工作台 __MONTH__</title>
- <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
- <style>
- *{box-sizing:border-box;margin:0;padding:0}
- body{background:#f6f4ef;color:#33333d;font-family:-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;font-size:14px;line-height:1.6}
- .wrap{max-width:1280px;margin:0 auto;padding:28px 62px 60px 32px}
- header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
- h1{font-size:21px;font-weight:700;color:#33333d}
- .brand{background:#ffe9b3;color:#8a6116;font-weight:600;font-size:12px;padding:5px 14px;border-radius:999px;letter-spacing:1px}
- .sub{color:#8f8d85;font-size:12px}
- .kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:20px 0 18px}
- .kpi{background:#fff;border:1px solid #e9e6dd;border-radius:16px;padding:16px 18px;cursor:pointer;transition:all .15s;box-shadow:0 1px 3px rgba(60,50,30,.05)}
- .kpi:hover{border-color:#c8cfe0;transform:translateY(-1px)}
- .kpi.on{border-color:#8fb0ea;background:#eef3fc}
- .kpi .v{font-size:22px;font-weight:700;font-variant-numeric:tabular-nums;white-space:nowrap;color:#33333d}
- .kpi .l{color:#8f8d85;font-size:12px;margin-top:2px}
- .kpi.hot .v{color:#e05a48}
- .kpi.hot.on{border-color:#efb3a8;background:#fdf1ee}
- .charts{display:grid;grid-template-columns:1fr 1fr 1fr;gap:12px;margin-bottom:18px}
- .panel{background:#fff;border:1px solid #e9e6dd;border-radius:16px;padding:16px 18px;box-shadow:0 1px 3px rgba(60,50,30,.05)}
- .panel h3{font-size:13px;font-weight:600;color:#7c7a72;margin-bottom:6px}
- .chart{height:230px}
- .bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:14px}
- .bar input,.bar select{background:#fff;border:1px solid #e0ddd4;color:#33333d;border-radius:10px;padding:8px 12px;font-size:13px;outline:none}
- .bar input:focus,.bar select:focus{border-color:#8fb0ea}
- .bar input{width:220px}
- .bar select{max-width:170px}
- table{width:100%;border-collapse:collapse;font-size:13px}
- th{color:#8f8d85;font-weight:500;font-size:12px;padding:9px 10px;border-bottom:1px solid #e9e6dd;text-align:left;white-space:nowrap;cursor:pointer;user-select:none}
- th.num,td.num{text-align:right;font-variant-numeric:tabular-nums}
- td{padding:10px;border-bottom:1px solid #f1efe8;vertical-align:middle}
- tr.row:hover td{background:#faf8f3;cursor:pointer}
- .nm{font-weight:500}.sid{color:#a5a29a;font-size:11px}
- .tag{display:inline-block;padding:1px 8px;border-radius:6px;font-size:11px;margin:1px 4px 1px 0;white-space:nowrap}
- .t-R3{background:#fde2dd;color:#d6442f}
- .t-R2{background:#fdeedd;color:#d0762c}
- .t-R1{background:#fdf5d7;color:#a8821a}
- .t-B{background:#eef3fc;color:#4a72c4}
- .t-T{background:#f0efea;color:#8f8d85}
- .dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
- .pager{display:flex;gap:10px;align-items:center;justify-content:flex-end;color:#8f8d85;font-size:12px;padding:12px 4px}
- .pager button{background:#fff;border:1px solid #e0ddd4;color:#33333d;border-radius:10px;padding:5px 14px;cursor:pointer}
- .pager button:hover{border-color:#8fb0ea}
- .pager button:disabled{opacity:.35;cursor:default}
- /* ---- 右侧菜单栏 ---- */
- .rail{position:fixed;right:0;top:50%;transform:translateY(-50%);z-index:8;display:flex;flex-direction:column;gap:6px;
- background:#fff;border:1px solid #e9e6dd;border-right:0;border-radius:16px 0 0 16px;padding:10px 7px;
- box-shadow:-2px 2px 10px rgba(60,50,30,.06)}
- .railitem{position:relative;background:none;border:0;cursor:pointer;font-family:inherit;font-size:13px;color:#6b695f;
- writing-mode:vertical-rl;letter-spacing:3px;padding:14px 5px;border-radius:10px;transition:all .15s}
- .railitem:hover{background:#f7f5f0;color:#33333d}
- .railitem.on{background:#eef3fc;color:#4a72c4;font-weight:600}
- .railitem .rdot{position:absolute;top:6px;left:50%;margin-left:-3px;width:6px;height:6px;border-radius:50%;background:#e05a48;display:none}
- .railitem.mod .rdot{display:block}
- /* ---- 考核指标面板 ---- */
- .thpanel{position:fixed;top:0;right:0;width:410px;max-width:94vw;height:100%;background:#fff;border-left:1px solid #e9e6dd;
- z-index:12;transform:translateX(102%);transition:transform .24s;display:flex;flex-direction:column;box-shadow:-6px 0 24px rgba(60,50,30,.10)}
- .thpanel.on{transform:translateX(0)}
- .thhead{padding:22px 22px 14px;border-bottom:1px solid #f1efe8}
- .thhead h4{font-size:16px;font-weight:700;display:flex;align-items:center;gap:8px}
- .thhead .pill{background:#fff4dc;color:#9a6d14;font-size:11px;font-weight:500;padding:2px 8px;border-radius:999px}
- .thhead p{color:#a5a29a;font-size:11.5px;margin-top:6px;line-height:1.7}
- .thbody{flex:1;overflow-y:auto;padding:6px 22px 16px}
- .thgroup{padding:14px 0;border-bottom:1px dashed #f1efe8}
- .thgroup:last-child{border-bottom:0}
- .thgroup .gt{font-size:13px;font-weight:600;color:#33333d;display:flex;align-items:center;justify-content:space-between}
- .thgroup .gh{color:#a5a29a;font-size:11px;margin-top:2px}
- .throw{display:grid;grid-template-columns:1fr 1fr 1fr;gap:8px;margin-top:9px}
- .throw.one{grid-template-columns:1fr 1fr}
- .throw.single{grid-template-columns:1fr}
- .thfield{display:flex;flex-direction:column;gap:3px}
- .thfield label{font-size:11px;color:#8f8d85}
- .thfield .inp{display:flex;align-items:center;background:#fbfaf7;border:1px solid #e6e3da;border-radius:10px;padding:0 9px;transition:border-color .15s}
- .thfield .inp:focus-within{border-color:#8fb0ea;background:#fff}
- .thfield input{width:100%;border:0;background:none;outline:none;font-family:inherit;font-size:13.5px;font-weight:600;
- color:#33333d;padding:8px 0;font-variant-numeric:tabular-nums}
- .thfield .u{font-size:11px;color:#a5a29a;padding-left:4px}
- .thfield.chg .inp{border-color:#f0b45c;background:#fffaf0}
- .thfoot{padding:14px 22px 12px;border-top:1px solid #f1efe8;display:flex;gap:10px;align-items:center}
- .thfoot button{font-family:inherit;font-size:13px;border-radius:12px;padding:10px 16px;cursor:pointer;border:1px solid #e0ddd4;background:#fff;color:#33333d}
- .thfoot button:hover{border-color:#8fb0ea}
- .thfoot .primary{flex:1;background:#5b87e8;border-color:#5b87e8;color:#fff;font-weight:600}
- .thfoot .primary:hover{background:#4a76d8}
- .thnote{color:#a5a29a;font-size:11px;padding:0 22px 18px;line-height:1.7}
- /* ---- 门店详情抽屉 ---- */
- .overlay{position:fixed;inset:0;background:rgba(40,35,25,.28);opacity:0;pointer-events:none;transition:opacity .2s;z-index:9}
- .overlay.on{opacity:1;pointer-events:auto}
- .overlay.hi{z-index:11}
- .drawer{position:fixed;top:0;right:-520px;width:500px;max-width:94vw;height:100%;background:#fff;border-left:1px solid #e9e6dd;
- z-index:10;transition:right .22s;overflow-y:auto;padding:26px 26px 40px}
- .drawer.on{right:0}
- .drawer h2{font-size:17px;font-weight:700;display:flex;gap:10px;align-items:center;flex-wrap:wrap}
- .drawer .sid{margin:4px 0 14px}
- .mrow{margin:13px 0}
- .mrow .lab{display:flex;justify-content:space-between;font-size:12px;color:#7c7a72}
- .mrow .track{position:relative;height:8px;background:#efede6;border-radius:5px;margin-top:5px}
- .mrow .fill{height:100%;border-radius:5px}
- .mrow .mk{position:absolute;top:-3px;width:2px;height:14px;background:#b8b5ac;opacity:.8}
- .flag{margin:8px 0;font-size:13px}
- .flag .act{color:#3f9c74;font-size:12px;margin-left:6px}
- .copybtn{margin-top:18px;width:100%;background:#5b87e8;border:1px solid #5b87e8;color:#fff;font-weight:600;border-radius:12px;padding:11px;font-size:13px;cursor:pointer}
- .copybtn:hover{background:#4a76d8}
- .hint{color:#a5a29a;font-size:11px;margin-top:8px;line-height:1.7}
- .toast{position:fixed;left:50%;bottom:34px;transform:translate(-50%,14px);background:#33333d;color:#fff;font-size:13px;
- padding:10px 20px;border-radius:999px;opacity:0;pointer-events:none;transition:all .22s;z-index:20;box-shadow:0 6px 20px rgba(0,0,0,.16)}
- .toast.on{opacity:1;transform:translate(-50%,0)}
- @media(max-width:1100px){.wrap{padding:22px 56px 50px 20px}.kpis{grid-template-columns:repeat(2,1fr)}}
- @media(max-width:960px){.charts{grid-template-columns:1fr}}
- </style>
- </head>
- <body>
- <div class="wrap">
- <header><div class="brand">怡呵美 · 水光会员店</div><h1>门店数据工作台 · __MONTH__</h1><span class="sub" id="ruleNote">规则来源:《门店考核指标》官方基准线</span><span class="sub">· 共 __COUNT__ 家门店</span></header>
- <div class="kpis" id="kpis"></div>
- <div class="charts">
- <div class="panel"><h3>问题命中分布(按类型)</h3><div class="chart" id="c1"></div></div>
- <div class="panel"><h3 id="c2title">核销金额分布</h3><div class="chart" id="c2"></div></div>
- <div class="panel"><h3>门店数 TOP15 城市</h3><div class="chart" id="c3"></div></div>
- </div>
- <div class="bar">
- <input id="q" placeholder="搜索门店名称或ID">
- <select id="fcity"><option value="">全部城市</option></select>
- <select id="fcat"><option value="">全部问题类型</option></select>
- <select id="fsort">
- <option value="sev">按严重度排序</option>
- <option value="amt">按核销金额(低→高)</option>
- <option value="score">按评分(低→高)</option>
- <option value="hx">按核销率(低→高)</option>
- <option value="visit">按访问量(高→低)</option>
- </select>
- <span class="sub" id="cnt"></span>
- </div>
- <div class="panel" style="padding:4px 10px">
- <table><thead><tr>
- <th>门店</th><th>城市</th>
- <th class="num">评分</th><th class="num">核销率</th><th class="num">核销金额</th><th class="num">退单率</th>
- <th>触发问题</th>
- </tr></thead><tbody id="tb"></tbody></table>
- <div class="pager"><span id="pginfo"></span><button id="prev">上一页</button><button id="next">下一页</button></div>
- </div>
- </div>
- <aside class="rail">
- <button class="railitem" id="btnTh">考核指标<span class="rdot"></span></button>
- <button class="railitem" id="btnExport">导出清单</button>
- </aside>
- <div class="overlay" id="ov"></div>
- <div class="drawer" id="dw"></div>
- <aside class="thpanel" id="thPanel">
- <div class="thhead">
- <h4>考核指标 <span class="pill">默认值</span><span id="thClose" style="margin-left:auto;cursor:pointer;color:#a5a29a;font-size:18px;line-height:1" title="关闭">×</span></h4>
- <p>当前数值来自《门店考核指标》官方基准线。修改后点【应用到工作台】,会按新阈值重算全部门店的检测结果。</p>
- </div>
- <div class="thbody" id="thBody"></div>
- <div class="thfoot">
- <button id="thReset">恢复默认</button>
- <button class="primary" id="thApply">应用到工作台</button>
- </div>
- <div class="thnote">自定义值保存在本机浏览器,下次打开仍在;换电脑或换人查看时是默认值。</div>
- </aside>
- <div class="toast" id="toast"></div>
- <script>
- const DATA = __DATA__;
- const DEFAULT_T = __THRESH__;
- const MONTH = '__MONTH__';
- const PAGESIZE = 50;
- const LSKEY = 'yhm_thresh_' + MONTH;
- let T = JSON.parse(JSON.stringify(DEFAULT_T));
- try{
- const saved = localStorage.getItem(LSKEY);
- if(saved){ const s = JSON.parse(saved); Object.keys(T).forEach(k=>{ if(k in s) T[k]=s[k]; }); }
- }catch(e){}
- const isCustom = ()=> JSON.stringify(T)!==JSON.stringify(DEFAULT_T);
- const state = {kpi:'ALL', city:'', cat:'', q:'', sort:'sev', page:0};
- /* ---------- 检测规则(与 detect.py 保持一致) ---------- */
- const LVW = {R3:3, R2:2, R1:1, B:.6, T:.3};
- const LVNAME = {R1:'预警线', R2:'一般严重线', R3:'非常严重线'};
- function band(v, lines){
- if(v==null) return null;
- if(v>lines[2]) return 'R3';
- if(v>lines[1]) return 'R2';
- if(v>lines[0]) return 'R1';
- return null;
- }
- function catOf(l){
- if(l.indexOf('商责退单率')===0) return '退单率超线';
- if(l.indexOf('投诉率')===0) return '投诉率超线';
- if(l.indexOf('差评率')===0) return '差评率超线';
- if(l.indexOf('评分')===0) return '评分不达标';
- if(l.indexOf('核销率')===0) return '核销率不达标';
- if(l.indexOf('本月零核销')===0) return '零核销';
- if(l.indexOf('核销金额')===0) return '核销金额未达标';
- if(l.indexOf('评价回复率')===0) return '回复率偏低';
- if(l.indexOf('月访问')===0) return '访问过低';
- if(l.indexOf('本月零视频')===0) return '零视频';
- return '其他';
- }
- function detectAll(){
- DATA.forEach(d=>{
- const f = [];
- let lv = band(d.td, T.tuDan);
- if(lv) f.push([lv, '商责退单率'+(d.td*100).toFixed(2)+'%超'+LVNAME[lv], '催办到店核销、主动处理退单商责, 避免平台处置']);
- lv = band(d.ts, T.touSu);
- if(lv) f.push([lv, '投诉率'+(d.ts*100).toFixed(2)+'%超线', '24小时内响应投诉工单, 复盘服务环节']);
- lv = band(d.cp, T.chaPing);
- if(lv) f.push([lv, '差评率('+d.cp_src+'口径)'+(d.cp*100).toFixed(2)+'%超线', '处理近30天差评, 主攻优质深度好评']);
- if(d.score!=null && d.score < T.score_base)
- f.push(['B', '评分'+d.score.toFixed(1)+'低于考核基准'+T.score_base, '引导好评+回复差评, 目标先回'+T.score_base+'再冲'+T.score_target]);
- if(d.hx_rate!=null && d.hx_rate < T.hx_rate_base)
- f.push(['B', '核销率'+(d.hx_rate*100).toFixed(0)+'%低于基准'+(T.hx_rate_base*100).toFixed(0)+'%', '电话/私信触达待核销客户, 加速到店']);
- if(d.hx_amt < T.hx_amt_base){
- if(d.hx_cnt===0) f.push(['B', '本月零核销', '检查团购在售状态与价格, 重新起量']);
- else f.push(['B', '核销金额'+Math.round(d.hx_amt)+'元低于基准'+T.hx_amt_base, '补齐在售品结构, 冲单量']);
- }
- if(d.reply!=null && d.reply < T.reply_base/100)
- f.push(['T', '评价回复率'+Math.round(d.reply*100)+'%偏低', '安排专人回复全部评价']);
- if(d.visit < T.visit_low)
- f.push(['T', '月访问仅'+d.visit+'次', '检查POI完整度, 点亮门店+更新AIGC素材']);
- if(d.videos===0)
- f.push(['T', '本月零视频', '后台AIGC工具定期产出图片/视频']);
- d.flags = f;
- d.sev = f.reduce((m,x)=>Math.max(m, LVW[x[0]]), 0);
- d.cats = f.map(x=>catOf(x[1]));
- });
- }
- const CITIES = [...new Set(DATA.map(d=>d.city))].sort();
- /* ---------- KPI ---------- */
- const KPI = [
- {k:'ALL', l:()=>'全部门店', hot:0, f:()=>true},
- {k:'R', l:()=>'超违规预警线(退单/投诉/差评)', hot:1, f:d=>d.flags.some(x=>x[0][0]==='R')},
- {k:'SCORE', l:()=>'评分低于基准'+T.score_base, hot:1, f:d=>d.score!=null&&d.score<T.score_base},
- {k:'HX', l:()=>'核销率低于基准'+(T.hx_rate_base*100).toFixed(0)+'%', hot:1, f:d=>d.hx_rate!=null&&d.hx_rate<T.hx_rate_base},
- {k:'ZERO', l:()=>'本月零核销', hot:1, f:d=>d.hx_cnt===0},
- ];
- function filtered(){
- const kf = KPI.find(k=>k.k===state.kpi).f;
- let rows = DATA.filter(kf);
- if(state.city) rows = rows.filter(d=>d.city===state.city);
- if(state.cat) rows = rows.filter(d=>d.cats.indexOf(state.cat)>=0);
- if(state.q){ const q=state.q.toLowerCase();
- rows = rows.filter(d=>d.name.toLowerCase().indexOf(q)>=0 || d.id.indexOf(q)>=0); }
- const nullLast = (v)=> v==null;
- const s = state.sort;
- rows.sort((a,b)=>{
- if(s==='amt') return (a.hx_amt||0)-(b.hx_amt||0);
- if(s==='visit') return (b.visit||0)-(a.visit||0);
- if(s==='score'){ if(nullLast(a.score)&&nullLast(b.score))return 0; if(nullLast(a.score))return 1; if(nullLast(b.score))return -1; return a.score-b.score; }
- if(s==='hx'){ if(nullLast(a.hx_rate)&&nullLast(b.hx_rate))return 0; if(nullLast(a.hx_rate))return 1; if(nullLast(b.hx_rate))return -1; return a.hx_rate-b.hx_rate; }
- return b.sev-a.sev || (a.hx_amt||0)-(b.hx_amt||0);
- });
- return rows;
- }
- function esc(s){ return String(s).replace(/&/g,'&').replace(/</g,'<'); }
- function pct(v,d){ return v==null?'-':(v*100).toFixed(d||0)+'%'; }
- function renderKpis(){
- document.getElementById('kpis').innerHTML = KPI.map(k=>{
- const n = DATA.filter(k.f).length;
- return '<div class="kpi'+(k.hot?' hot':'')+(state.kpi===k.k?' on':'')+'" data-k="'+k.k+'"><div class="v">'+n+' 家</div><div class="l">'+k.l()+'</div></div>';
- }).join('');
- document.querySelectorAll('.kpi').forEach(el=>el.onclick=()=>{
- state.kpi = el.dataset.k; state.page=0; renderKpis(); renderTable();
- });
- }
- /* ---------- 图表 ---------- */
- let ch1=null, ch2=null, ch3=null;
- function renderCharts(){
- if(!window.echarts) return;
- const ax = {axisLine:{lineStyle:{color:'#e0ddd4'}}, axisLabel:{color:'#8f8d85',fontSize:11}, splitLine:{lineStyle:{color:'#f1efe8'}}};
- if(!ch1) ch1 = echarts.init(document.getElementById('c1'));
- const cnt = {};
- DATA.forEach(d=>d.cats.forEach(c=>cnt[c]=(cnt[c]||0)+1));
- const e1 = Object.entries(cnt).sort((a,b)=>a[1]-b[1]);
- ch1.setOption({grid:{left:110,top:8,bottom:24,right:30},
- xAxis:{type:'value',...ax}, yAxis:{type:'category',data:e1.map(e=>e[0]),axisLabel:{color:'#6b695f',fontSize:11}},
- tooltip:{}, series:[{type:'bar',data:e1.map(e=>e[1]),barWidth:12,itemStyle:{color:'#7fa5ec',borderRadius:[0,4,4,0]}}]}, true);
- let b = [0,0,0,0];
- DATA.forEach(d=>{ const a=d.hx_amt||0;
- if(a===0) b[0]++; else if(a<1000) b[1]++; else if(a<T.hx_amt_base) b[2]++; else b[3]++; });
- const baseLabel = T.hx_amt_base>=10000 ? '达标≥'+(T.hx_amt_base/10000).toFixed(1)+'万' : '达标≥'+T.hx_amt_base;
- document.getElementById('c2title').textContent = '核销金额分布(基准 '+T.hx_amt_base+' 元)';
- if(!ch2) ch2 = echarts.init(document.getElementById('c2'));
- ch2.setOption({grid:{left:44,top:14,bottom:40,right:16},
- xAxis:{type:'category',data:['零核销','0-1千','1千-基准',baseLabel],...ax},
- yAxis:{type:'value',...ax},
- tooltip:{}, series:[{type:'bar',data:[
- {value:b[0],itemStyle:{color:'#e9695f'}},{value:b[1],itemStyle:{color:'#f2a65a'}},
- {value:b[2],itemStyle:{color:'#f5d472'}},{value:b[3],itemStyle:{color:'#6fc49a'}}],
- barWidth:26,itemStyle:{borderRadius:[4,4,0,0]},label:{show:true,position:'top',color:'#8f8d85',fontSize:11}}]}, true);
- const cc = {};
- DATA.forEach(d=>cc[d.city]=(cc[d.city]||0)+1);
- const e3 = Object.entries(cc).sort((a,b)=>b[1]-a[1]).slice(0,15).reverse();
- if(!ch3) ch3 = echarts.init(document.getElementById('c3'));
- ch3.setOption({grid:{left:64,top:8,bottom:24,right:30},
- xAxis:{type:'value',...ax}, yAxis:{type:'category',data:e3.map(e=>e[0]),axisLabel:{color:'#6b695f',fontSize:11}},
- tooltip:{}, series:[{type:'bar',data:e3.map(e=>e[1]),barWidth:11,itemStyle:{color:'#9fc0f0',borderRadius:[0,4,4,0]}}]}, true);
- }
- /* ---------- 表格 ---------- */
- function renderTable(){
- const rows = filtered();
- const pages = Math.max(1, Math.ceil(rows.length/PAGESIZE));
- if(state.page>=pages) state.page = pages-1;
- const slice = rows.slice(state.page*PAGESIZE, state.page*PAGESIZE+PAGESIZE);
- const sevColor = s=> s>=3?'#e05a48': s>=2?'#f08a4b': s>=1?'#f0b45c': s>=.6?'#7fa5ec':'#c9c6bd';
- document.getElementById('tb').innerHTML = slice.map(d=>
- '<tr class="row" data-id="'+d.id+'"><td><div class="nm"><span class="dot" style="background:'+sevColor(d.sev)+'"></span>'+esc(d.name)+'</div><div class="sid">'+d.id+'</div></td>'+
- '<td>'+esc(d.city)+'</td>'+
- '<td class="num">'+(d.score==null?'-':d.score.toFixed(1))+'</td>'+
- '<td class="num">'+pct(d.hx_rate)+'</td>'+
- '<td class="num">'+Math.round(d.hx_amt).toLocaleString()+'</td>'+
- '<td class="num">'+pct(d.td,2)+'</td>'+
- '<td style="max-width:330px">'+d.flags.slice(0,4).map(f=>'<span class="tag t-'+f[0]+'">'+esc(f[1])+'</span>').join('')+(d.flags.length>4?'<span class="tag t-T">+'+(d.flags.length-4)+'</span>':'')+'</td></tr>'
- ).join('') || '<tr><td colspan="7" style="color:#a5a29a;padding:30px;text-align:center">没有符合筛选条件的门店</td></tr>';
- document.querySelectorAll('tr.row').forEach(el=>el.onclick=()=>openDrawer(el.dataset.id));
- document.getElementById('cnt').textContent = '共 '+rows.length+' 家';
- document.getElementById('pginfo').textContent = '第 '+(state.page+1)+' / '+pages+' 页';
- document.getElementById('prev').disabled = state.page===0;
- document.getElementById('next').disabled = state.page>=pages-1;
- }
- /* ---------- 门店详情 ---------- */
- function buildMetrics(){
- return [
- {k:'评分', v:d=>d.score, fmt:v=>v.toFixed(1), max:5, base:T.score_base, target:T.score_target, dir:'high'},
- {k:'核销率', v:d=>d.hx_rate, fmt:v=>pct(v), max:1, base:T.hx_rate_base, target:T.hx_rate_target, dir:'high'},
- {k:'核销金额', v:d=>d.hx_amt, fmt:v=>Math.round(v).toLocaleString()+'元', max:T.hx_amt_base*2, base:T.hx_amt_base, target:T.hx_amt_base, dir:'high'},
- {k:'商责退单率', v:d=>d.td, fmt:v=>pct(v,2), max:T.tuDan[2]*1.1, warn:T.tuDan, dir:'low'},
- {k:'投诉率', v:d=>d.ts, fmt:v=>pct(v,2), max:T.touSu[2]*1.4, warn:T.touSu, dir:'low'},
- {k:'差评率', v:d=>d.cp, fmt:v=>v==null?'样本不足':pct(v,2), max:T.chaPing[2]*1.4, warn:T.chaPing, dir:'low'},
- {k:'评价回复率', v:d=>d.reply, fmt:v=>pct(v), max:1, base:T.reply_base/100, target:1, dir:'high'},
- ];
- }
- function barColor(m, v){
- if(v==null) return '#d5d2c8';
- if(m.dir==='high') return v<m.base?'#e9695f': v<m.target?'#f2a65a':'#6fc49a';
- return v>m.warn[2]?'#e05a48': v>m.warn[1]?'#f08a4b': v>m.warn[0]?'#f2a65a':'#6fc49a';
- }
- function openDrawer(id){
- const d = DATA.find(x=>x.id===id);
- if(!d) return;
- closePanel();
- let h = '<h2>'+esc(d.name)+'</h2><div class="sid">'+d.id+' · '+esc(d.city)+' · 严重度 '+d.sev.toFixed(1)+'</div>';
- h += '<div>' + d.flags.map(f=>'<span class="tag t-'+f[0]+'">'+esc(f[1])+'</span>').join('') + (d.flags.length?'':'<span class="tag t-T">本月无明显异常</span>') + '</div>';
- h += buildMetrics().map(m=>{
- const v = m.v(d);
- const w = v==null?0:Math.min(v/m.max*100,100);
- let marks = '';
- if(m.warn) marks = m.warn.map(x=>'<span class="mk" style="left:'+(x/m.max*100).toFixed(1)+'%"></span>').join('');
- else marks = '<span class="mk" style="left:'+(m.base/m.max*100).toFixed(1)+'%"></span><span class="mk" style="left:'+(m.target/m.max*100).toFixed(1)+'%;background:#6fc49a"></span>';
- return '<div class="mrow"><div class="lab"><span>'+m.k+'</span><span>'+(v==null?'-':m.fmt(v))+'</span></div>'+
- '<div class="track"><div class="fill" style="width:'+w.toFixed(1)+'%;background:'+barColor(m,v)+'"></div>'+marks+'</div></div>';
- }).join('');
- h += '<div class="mrow" style="color:#8f8d85;font-size:12px">月访问 '+d.visit.toLocaleString()+' 次 · 本月新发视频 '+d.videos+' 个 · 商责退单率 '+pct(d.td,2)+'</div>';
- if(d.flags.length){
- h += '<div style="margin-top:14px;font-size:13px">' + d.flags.map(f=>'<div class="flag"><span class="tag t-'+f[0]+'">'+esc(f[1])+'</span><span class="act">'+esc(f[2])+'</span></div>').join('') + '</div>';
- }
- h += '<button class="copybtn" id="cp">一键复制该店反馈文案(反馈格式)</button><div class="hint">复制后可直接粘贴给门店/督导,格式与《门店评估后反馈格式》一致。灰色竖线=基准线,绿色竖线=目标线。</div>';
- const dw = document.getElementById('dw');
- dw.innerHTML = h;
- dw.classList.add('on'); document.getElementById('ov').classList.add('on');
- document.getElementById('cp').onclick = ()=>copyFeedback(d);
- }
- function feedbackText(d){
- const L = [];
- L.push('✅经营数据概览:');
- L.push(MONTH+':门店页访问'+d.visit.toLocaleString()+' 核销'+Math.round(d.hx_amt).toLocaleString()+'元 评分'+(d.score==null?'-':d.score.toFixed(1)));
- L.push('===');
- L.push('✅风险或限流因素:');
- d.flags.filter(f=>f[0][0]==='R'||f[0]==='B').forEach((f,i)=>L.push((i+1)+'、'+f[1]));
- L.push('===');
- L.push('✅整改建议:');
- const seen = new Set();
- d.flags.forEach(f=>{ if(!seen.has(f[2])){ seen.add(f[2]); L.push('· '+f[2]); } });
- return L.join('\n');
- }
- function copyFeedback(d){
- const t = feedbackText(d);
- if(navigator.clipboard&&navigator.clipboard.writeText){ navigator.clipboard.writeText(t); }
- else{ const ta=document.createElement('textarea'); ta.value=t; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); ta.remove(); }
- const b=document.getElementById('cp'); b.textContent='已复制 ✓'; setTimeout(()=>b.textContent='一键复制该店反馈文案(反馈格式)',1600);
- }
- /* ---------- 考核指标面板 ---------- */
- const SPEC = [
- {g:'门店评分', hint:'低于基准即判「评分不达标」', items:[
- {p:'score_base', l:'考核基准', step:'0.1'},
- {p:'score_target', l:'目标值', step:'0.1'}]},
- {g:'核销率', hint:'核销券数 ÷ 意向成交券数', pct:1, items:[
- {p:'hx_rate_base', l:'考核基准', step:'1'},
- {p:'hx_rate_target', l:'目标值', step:'1'}]},
- {g:'核销金额', hint:'单店月度核销金额,低于基准即提醒', items:[
- {p:'hx_amt_base', l:'考核基准(元)', step:'500'}]},
- {g:'商责退单率', hint:'超出即计入违规,分三级预警', pct:1, items:[
- {p:'tuDan.0', l:'预警线'}, {p:'tuDan.1', l:'一般严重'}, {p:'tuDan.2', l:'非常严重'}]},
- {g:'投诉率', hint:'超出即计入违规,分三级预警', pct:1, items:[
- {p:'touSu.0', l:'预警线'}, {p:'touSu.1', l:'一般严重'}, {p:'touSu.2', l:'非常严重'}]},
- {g:'差评率', hint:'样本不足时不判定(新增<10 且累计<20)', pct:1, items:[
- {p:'chaPing.0', l:'预警线'}, {p:'chaPing.1', l:'一般严重'}, {p:'chaPing.2', l:'非常严重'}]},
- {g:'评价回复率', hint:'低于此比例判「回复率偏低」', unit:'%', items:[
- {p:'reply_base', l:'最低要求'}]},
- {g:'月访问量', hint:'低于此值判「访问过低」', items:[
- {p:'visit_low', l:'过低线(次)', step:'10'}]},
- ];
- function getP(obj, path){
- const ks = path.split('.');
- let v = obj;
- for(const k of ks){ if(v==null) return null; v = v[k]; }
- return v;
- }
- function setP(obj, path, val){
- const ks = path.split('.');
- let v = obj;
- for(let i=0;i<ks.length-1;i++){ v = v[ks[i]]; }
- v[ks[ks.length-1]] = val;
- }
- function uiVal(path, isPct){
- const raw = getP(T, path);
- return isPct ? +(raw*100).toFixed(3) : raw;
- }
- function uiValFrom(t, path, isPct){
- const raw = getP(t, path);
- return isPct ? +(raw*100).toFixed(3) : raw;
- }
- function unitOf(g){
- if(g.unit) return g.unit;
- if(g.pct) return '%';
- if(g.g.indexOf('金额')>=0) return '元';
- if(g.g.indexOf('访问')>=0) return '次';
- return '分';
- }
- function buildPanel(){
- document.getElementById('thBody').innerHTML = SPEC.map(g=>{
- const cls = g.items.length===3 ? '' : (g.items.length===2 ? ' one' : ' single');
- return '<div class="thgroup"><div class="gt">'+g.g+'</div><div class="gh">'+g.hint+'</div>'+
- '<div class="throw'+cls+'">'+g.items.map(it=>
- '<div class="thfield"><label>'+it.l+'</label><div class="inp">'+
- '<input type="number" step="'+(it.step||'0.01')+'" data-p="'+it.p+'" data-pct="'+(g.pct?1:0)+'" value="'+uiVal(it.p, g.pct?1:0)+'">'+
- '<span class="u">'+unitOf(g)+'</span></div></div>'
- ).join('')+'</div></div>';
- }).join('');
- document.querySelectorAll('#thBody input').forEach(inp=>{
- inp.oninput = ()=>{
- const def = uiValFrom(DEFAULT_T, inp.dataset.p, inp.dataset.pct==='1');
- const cur = parseFloat(inp.value);
- inp.closest('.thfield').classList.toggle('chg', !isNaN(cur) && Math.abs(cur-def)>1e-9);
- };
- });
- }
- function readPanel(){
- const next = JSON.parse(JSON.stringify(T));
- const errs = [];
- document.querySelectorAll('#thBody input').forEach(inp=>{
- const v = parseFloat(inp.value);
- if(isNaN(v)){ errs.push('有输入框为空'); return; }
- setP(next, inp.dataset.p, inp.dataset.pct==='1' ? v/100 : v);
- });
- [['tuDan','商责退单率'],['touSu','投诉率'],['chaPing','差评率']].forEach(pair=>{
- const a = next[pair[0]];
- if(!(a[0]>0 && a[1]>a[0] && a[2]>a[1])) errs.push(pair[1]+' 三级阈值需递增(预警 < 一般 < 非常严重)');
- });
- if(next.score_base>=next.score_target) errs.push('评分目标值应高于考核基准');
- if(next.hx_rate_base>=next.hx_rate_target) errs.push('核销率目标值应高于考核基准');
- return {next:next, errs:[...new Set(errs)]};
- }
- function toast(msg){
- const t = document.getElementById('toast');
- t.textContent = msg; t.classList.add('on');
- clearTimeout(t._tm); t._tm = setTimeout(()=>t.classList.remove('on'), 2400);
- }
- function fillSelects(){
- const csel = document.getElementById('fcity');
- csel.innerHTML = '<option value="">全部城市</option>';
- CITIES.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; csel.appendChild(o); });
- const cats = [...new Set(DATA.flatMap(d=>d.cats))].sort();
- const ksel = document.getElementById('fcat');
- ksel.innerHTML = '<option value="">全部问题类型</option>';
- cats.forEach(c=>{ const o=document.createElement('option'); o.value=c; o.textContent=c; ksel.appendChild(o); });
- if(state.city && CITIES.indexOf(state.city)<0){ state.city=''; csel.value=''; }
- if(state.cat && cats.indexOf(state.cat)<0){ state.cat=''; ksel.value=''; }
- csel.value = state.city; ksel.value = state.cat;
- }
- function refreshAll(){
- detectAll();
- fillSelects();
- renderKpis();
- renderCharts();
- renderTable();
- document.getElementById('ruleNote').textContent = isCustom()
- ? '规则:自定义考核指标(默认值来自《门店考核指标》)'
- : '规则来源:《门店考核指标》官方基准线';
- document.getElementById('btnTh').classList.toggle('mod', isCustom());
- }
- const OVEL = document.getElementById('ov');
- function openPanel(){
- buildPanel();
- closeDrawer();
- document.getElementById('thPanel').classList.add('on');
- document.getElementById('btnTh').classList.add('on');
- OVEL.classList.add('on','hi');
- }
- function closePanel(){
- document.getElementById('thPanel').classList.remove('on');
- document.getElementById('btnTh').classList.remove('on');
- OVEL.classList.remove('hi');
- }
- function closeDrawer(){
- document.getElementById('dw').classList.remove('on');
- document.getElementById('ov').classList.remove('on');
- }
- function exportCsv(){
- const rows = filtered();
- const head = ['门店ID','门店名称','城市','评分','核销率','核销金额','商责退单率','投诉率','差评率','评价回复率','月访问','本月新视频','触发问题','建议动作'];
- const lines = [head.join(',')];
- rows.forEach(d=>{
- const acts = [...new Set(d.flags.map(f=>f[2]))];
- const cells = [d.id, d.name, d.city,
- d.score==null?'':d.score.toFixed(1),
- d.hx_rate==null?'':(d.hx_rate*100).toFixed(1)+'%',
- Math.round(d.hx_amt),
- (d.td*100).toFixed(2)+'%', (d.ts*100).toFixed(2)+'%',
- d.cp==null?'':(d.cp*100).toFixed(2)+'%',
- d.reply==null?'':Math.round(d.reply*100)+'%',
- d.visit, d.videos,
- d.flags.map(f=>f[1]).join(' | '), acts.join(' ;')];
- lines.push(cells.map(c=>'"'+String(c).replace(/"/g,'""')+'"').join(','));
- });
- const blob = new Blob(['\ufeff'+lines.join('\r\n')], {type:'text/csv;charset=utf-8'});
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = '门店检测清单_'+MONTH+(isCustom()?'_自定义阈值':'')+'.csv';
- document.body.appendChild(a); a.click(); a.remove();
- toast('已导出 '+rows.length+' 家门店清单');
- }
- /* ---------- 事件绑定 ---------- */
- OVEL.onclick = ()=>{ closeDrawer(); closePanel(); };
- document.getElementById('thClose').onclick = closePanel;
- document.addEventListener('keydown', e=>{ if(e.key==='Escape'){ closeDrawer(); closePanel(); } });
- document.getElementById('q').oninput = e=>{ state.q=e.target.value.trim(); state.page=0; renderTable(); };
- document.getElementById('fcity').onchange = e=>{ state.city=e.target.value; state.page=0; renderTable(); };
- document.getElementById('fcat').onchange = e=>{ state.cat=e.target.value; state.page=0; renderTable(); };
- document.getElementById('fsort').onchange = e=>{ state.sort=e.target.value; state.page=0; renderTable(); };
- document.getElementById('prev').onclick = ()=>{ state.page--; renderTable(); };
- document.getElementById('next').onclick = ()=>{ state.page++; renderTable(); };
- document.getElementById('btnTh').onclick = ()=>{ document.getElementById('thPanel').classList.contains('on') ? closePanel() : openPanel(); };
- document.getElementById('btnExport').onclick = exportCsv;
- document.getElementById('thApply').onclick = ()=>{
- const r = readPanel();
- if(r.errs.length){ toast('请检查:'+r.errs[0]); return; }
- T = r.next;
- try{ localStorage.setItem(LSKEY, JSON.stringify(T)); }catch(e){}
- closePanel();
- refreshAll();
- toast('已按新阈值重算 '+DATA.length+' 家门店');
- };
- document.getElementById('thReset').onclick = ()=>{
- T = JSON.parse(JSON.stringify(DEFAULT_T));
- try{ localStorage.removeItem(LSKEY); }catch(e){}
- buildPanel();
- refreshAll();
- toast('已恢复官方默认基准线');
- };
- /* ---------- 初始化 ---------- */
- detectAll();
- fillSelects();
- renderKpis(); renderCharts(); renderTable();
- document.getElementById('ruleNote').textContent = isCustom()
- ? '规则:自定义考核指标(默认值来自《门店考核指标》)'
- : '规则来源:《门店考核指标》官方基准线';
- document.getElementById('btnTh').classList.toggle('mod', isCustom());
- window.addEventListener('resize', ()=>{ if(ch1)ch1.resize(); if(ch2)ch2.resize(); if(ch3)ch3.resize(); });
- if(location.hash==='#TH'){ openPanel(); }
- else if(location.hash.length>2){ openDrawer(location.hash.slice(1)); }
- </script>
- </body>
- </html>
- """
- def main():
- args = sys.argv[1:]
- src = args[0] if args and args[0].endswith('.csv') else os.path.join(BASE, '门店数据_10月.csv')
- month = '2026-10'
- if '--month' in args:
- month = args[args.index('--month') + 1]
- rows = list(csv.DictReader(open(src, encoding='utf-8-sig')))
- metrics, conflicts = detect(rows)
- # 只传原始字段,检测逻辑放到前端,阈值改动后可直接重算
- data = [dict(id=m['门店ID'], name=m['门店名称'], city=m['城市'],
- score=m['评分'], hx_rate=m['核销率'], hx_amt=m['核销金额'],
- hx_cnt=m['核销券数'], td=m['商责退单率'], ts=m['投诉率'],
- cp=m['差评率'], cp_src=m['差评口径'], reply=m['回复率'],
- visit=int(m['月访问']), videos=int(m['新视频']))
- for m in metrics]
- html = (TEMPLATE
- .replace('__DATA__', json.dumps(data, ensure_ascii=False, separators=(',', ':')))
- .replace('__THRESH__', json.dumps(THRESH))
- .replace('__MONTH__', month)
- .replace('__COUNT__', str(len(data))))
- out = os.path.join(OUT, f'门店工作台_{month.replace("-", "")}.html')
- with open(out, 'w', encoding='utf-8') as fp:
- fp.write(html)
- print(f'工作台已生成: {out} ({os.path.getsize(out) // 1024} KB, {len(data)} 家门店)')
- if __name__ == '__main__':
- main()
|