- 下载 chart.min.js 到本地(200KB) - analysis.html 引用改为 /chart.min.js - Dockerfile 添加 chart.min.js 复制 - 避免 CDN 访问问题导致图表无法渲染
173 lines
11 KiB
HTML
173 lines
11 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="zh-CN">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Cache-Control" content="no-cache">
|
|
<title>数据分析</title>
|
|
<script src="/chart.min.js"></script>
|
|
<style>
|
|
:root{--bg:#f5f5f7;--card:rgba(255,255,255,.8);--border:rgba(0,0,0,.06);--text:#1d1d1f;--text2:#6e6e73;--text3:#aeaeb2;--blue:#0071e3;--green:#34c759;--red:#ff3b30;--orange:#ff9500;--purple:#af52de;--radius:16px;--radius-sm:10px;--radius-xs:8px}
|
|
*{margin:0;padding:0;box-sizing:border-box}
|
|
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px}
|
|
.header{background:rgba(255,255,255,.72);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:40}
|
|
.header-inner{max-width:1200px;margin:0 auto;padding:0 32px;height:56px;display:flex;align-items:center;justify-content:space-between}
|
|
.header-left{display:flex;align-items:center;gap:10px;flex-shrink:0}
|
|
.header-logo{width:28px;height:28px;background:linear-gradient(135deg,#0071e3,#5ac8fa);border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:14px;color:#fff;font-weight:700}
|
|
.header h1{font-size:16px;font-weight:600;color:var(--text);letter-spacing:-.2px;white-space:nowrap;margin:0}
|
|
.nav-tabs{display:flex;gap:2px;background:rgba(0,0,0,.04);border-radius:var(--radius-xs);padding:3px}
|
|
.nav-tab{padding:7px 20px;border-radius:6px;font-size:13px;font-weight:500;color:var(--text2);text-decoration:none;transition:all .2s;cursor:pointer;white-space:nowrap}
|
|
.nav-tab:hover{color:var(--text);background:rgba(255,255,255,.5)}
|
|
.nav-tab.active{background:#fff;color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
.header-right{display:flex;align-items:center;gap:6px;flex-shrink:0}
|
|
.header-right a{color:var(--text2);text-decoration:none;font-size:12px;padding:6px 14px;border-radius:var(--radius-xs);transition:all .2s;white-space:nowrap}
|
|
.header-right a:hover{background:rgba(0,0,0,.04);color:var(--text)}
|
|
.header-right .admin-link{color:var(--blue);font-weight:500}
|
|
.header-right .logout-link{color:var(--red)}
|
|
.user-info{color:var(--text);font-size:13px;font-weight:500;cursor:pointer;padding:6px 14px;border-radius:var(--radius-xs);transition:all .2s;background:rgba(0,0,0,.02)}
|
|
.user-info:hover{background:rgba(0,0,0,.06)}
|
|
.container{max-width:1120px;margin:0 auto;padding:24px}
|
|
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
|
|
.stat-card{background:var(--card);border-radius:var(--radius-sm);padding:20px;text-align:center;border:1px solid var(--border)}
|
|
.stat-card .lbl{font-size:12px;color:var(--text3);margin-bottom:6px;font-weight:500}
|
|
.stat-card .val{font-size:24px;font-weight:700}
|
|
.stat-card .val.blue{color:var(--blue)}.stat-card .val.green{color:var(--green)}.stat-card .val.red{color:var(--red)}.stat-card .val.orange{color:var(--orange)}
|
|
.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-bottom:24px}
|
|
.chart-card{background:var(--card);border-radius:var(--radius);border:1px solid var(--border);padding:20px;box-shadow:0 1px 3px rgba(0,0,0,.04)}
|
|
.chart-card h3{font-size:14px;font-weight:600;margin-bottom:16px;color:var(--text)}
|
|
.chart-wrap{position:relative;height:280px}
|
|
.full-width{grid-column:1/-1}
|
|
.empty-tip{text-align:center;padding:40px;color:var(--text3);font-size:13px}
|
|
@media(max-width:900px){.stats-row{grid-template-columns:repeat(2,1fr)}.charts-grid{grid-template-columns:1fr}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-inner">
|
|
<div class="header-left">
|
|
<div class="header-logo">D</div>
|
|
<h1>债务管理</h1>
|
|
</div>
|
|
<div class="nav-tabs">
|
|
<a href="/" class="nav-tab">首页</a>
|
|
<a href="/analysis.html" class="nav-tab active">分析</a>
|
|
</div>
|
|
<div class="header-right">
|
|
<a href="/admin.html" class="admin-link" id="adminLink" style="display:none">管理</a>
|
|
<a href="#" class="logout-link" onclick="doLogout()">退出</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<div class="stats-row" id="statsRow"></div>
|
|
<div class="charts-grid" id="chartsGrid">
|
|
<div class="chart-card"><h3>借款总额 vs 已还总额</h3><div class="chart-wrap"><canvas id="chartPie"></canvas></div></div>
|
|
<div class="chart-card"><h3>债权人分布</h3><div class="chart-wrap"><canvas id="chartCreditor"></canvas></div></div>
|
|
<div class="chart-card"><h3>还款方式分布</h3><div class="chart-wrap"><canvas id="chartMethod"></canvas></div></div>
|
|
<div class="chart-card"><h3>利率分布</h3><div class="chart-wrap"><canvas id="chartRate"></canvas></div></div>
|
|
<div class="chart-card full-width"><h3>月度还款计划</h3><div class="chart-wrap"><canvas id="chartMonth"></canvas></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API='/api/v1';
|
|
let token=localStorage.getItem('access_token')||'';
|
|
let refreshToken=localStorage.getItem('refresh_token')||'';
|
|
let currentUser=null;
|
|
let loans=[];
|
|
const $=id=>document.getElementById(id);
|
|
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
|
|
|
|
async function apiFetch(path,opts={}){
|
|
const headers={'Content-Type':'application/json',...opts.headers};
|
|
if(token)headers['Authorization']='Bearer '+token;
|
|
let res=await fetch(API+path,{...opts,headers});
|
|
if(res.status===401&&refreshToken){
|
|
const r=await fetch(API+'/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refresh_token:refreshToken})});
|
|
if(r.ok){const d=await r.json();token=d.access_token;refreshToken=d.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);headers['Authorization']='Bearer '+token;res=await fetch(API+path,{...opts,headers});}
|
|
else{doLogout();return null;}
|
|
}
|
|
return res;
|
|
}
|
|
|
|
async function checkAuth(){
|
|
if(!token){window.location.href='/';return false;}
|
|
const res=await apiFetch('/auth/profile');
|
|
if(!res||!res.ok){window.location.href='/';return false;}
|
|
currentUser=await res.json();
|
|
if(currentUser.role==='admin')$('adminLink').style.display='';
|
|
return true;
|
|
}
|
|
|
|
function doLogout(){token='';refreshToken='';currentUser=null;localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');window.location.href='/';}
|
|
|
|
async function loadLoans(){const res=await apiFetch('/loans');if(!res||!res.ok)return;loans=await res.json();renderAll();}
|
|
|
|
function renderAll(){
|
|
if(!loans.length){$('statsRow').innerHTML='<div class="empty-tip">暂无数据</div>';return;}
|
|
renderStats();renderPie();renderCreditor();renderMethod();renderRate();renderMonth();
|
|
}
|
|
|
|
function renderStats(){
|
|
let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;
|
|
loans.forEach(l=>{
|
|
totalAmt+=l.amount;totalN+=l.periods;
|
|
totalInt+=l.schedule.reduce((a,r)=>a+r.it,0);
|
|
paidN+=l.paid.length;
|
|
totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);
|
|
});
|
|
const totalPay=totalAmt+totalInt;
|
|
$('statsRow').innerHTML=`
|
|
<div class="stat-card"><div class="lbl">借款总额</div><div class="val blue">¥${fmt(totalAmt)}</div></div>
|
|
<div class="stat-card"><div class="lbl">总利息</div><div class="val red">¥${fmt(totalInt)}</div></div>
|
|
<div class="stat-card"><div class="lbl">已还</div><div class="val green">¥${fmt(totalPaid)}</div></div>
|
|
<div class="stat-card"><div class="lbl">还款进度</div><div class="val orange">${paidN}/${totalN}期</div></div>`;
|
|
}
|
|
|
|
function renderPie(){
|
|
let totalAmt=0,totalPaid=0;
|
|
loans.forEach(l=>{
|
|
totalAmt+=l.amount;
|
|
totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);
|
|
});
|
|
const remain=Math.max(0,totalAmt+loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid);
|
|
new Chart($('chartPie'),{type:'doughnut',data:{labels:['已还本金','待还本金','已还利息','待还利息'],datasets:[{data:[totalPaid*0.85,Math.max(0,totalAmt-totalPaid*0.85),totalPaid*0.15,Math.max(0,loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid*0.15)],backgroundColor:['#34c759','#0071e3','#30d158','#5ac8fa']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}});
|
|
}
|
|
|
|
function renderCreditor(){
|
|
const map={};loans.forEach(l=>{map[l.name]=(map[l.name]||0)+l.amount;});
|
|
const sorted=Object.entries(map).sort((a,b)=>b[1]-a[1]).slice(0,8);
|
|
new Chart($('chartCreditor'),{type:'bar',data:{labels:sorted.map(s=>s[0]),datasets:[{label:'借款金额',data:sorted.map(s=>s[1]),backgroundColor:['#0071e3','#34c759','#ff9500','#af52de','#ff3b30','#5ac8fa','#30d158','#ff6b35']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:11}}}}}});
|
|
}
|
|
|
|
function renderMethod(){
|
|
const map={};loans.forEach(l=>{const m=l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本';map[m]=(map[m]||0)+1;});
|
|
new Chart($('chartMethod'),{type:'doughnut',data:{labels:Object.keys(map),datasets:[{data:Object.values(map),backgroundColor:['#0071e3','#ff9500','#af52de']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}});
|
|
}
|
|
|
|
function renderRate(){
|
|
const buckets={'0%':0,'0-5%':0,'5-10%':0,'10-15%':0,'15-20%':0,'20%+':0};
|
|
loans.forEach(l=>{
|
|
const r=l.rate;
|
|
if(r===0)buckets['0%']++;
|
|
else if(r<5)buckets['0-5%']++;
|
|
else if(r<10)buckets['5-10%']++;
|
|
else if(r<15)buckets['10-15%']++;
|
|
else if(r<20)buckets['15-20%']++;
|
|
else buckets['20%+']++;
|
|
});
|
|
new Chart($('chartRate'),{type:'bar',data:{labels:Object.keys(buckets),datasets:[{label:'借款数量',data:Object.values(buckets),backgroundColor:['#34c759','#5ac8fa','#0071e3','#ff9500','#ff6b35','#ff3b30']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{stepSize:1}},x:{ticks:{font:{size:11}}}}}});
|
|
}
|
|
|
|
function renderMonth(){
|
|
const map={};loans.forEach(l=>{l.schedule.forEach(r=>{const ym=r.date.slice(0,7);if(!map[ym])map[ym]={total:0,paid:0};map[ym].total+=r.pay;if(l.paid.includes(r.p))map[ym].paid+=r.pay;});});
|
|
const months=Object.keys(map).sort().slice(0,12);
|
|
new Chart($('chartMonth'),{type:'bar',data:{labels:months.map(m=>m.replace('-','年')+'月'),datasets:[{label:'应还',data:months.map(m=>map[m].total),backgroundColor:'rgba(0,113,227,.3)',borderColor:'#0071e3',borderWidth:1},{label:'已还',data:months.map(m=>map[m].paid),backgroundColor:'rgba(52,199,89,.5)',borderColor:'#34c759',borderWidth:1}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:10}}}}}});
|
|
}
|
|
|
|
checkAuth().then(ok=>{if(ok)loadLoans();});
|
|
</script>
|
|
</body>
|
|
</html>
|