feat: 新增数据分析页面

- 添加导航栏:首页(原有功能)+ 分析(新页面)
- 分析页面包含 5 个专业图表:
  - 借款总额 vs 已还总额(环形图)
  - 债权人分布(柱状图)
  - 还款方式分布(环形图)
  - 利率分布(柱状图)
  - 月度还款计划(柱状图)
- 使用 Chart.js 图表库
- 首页和分析页面共享导航栏样式
This commit is contained in:
OP
2026-06-28 23:38:47 +08:00
parent a49a5a7cff
commit 1bb826e7ac
3 changed files with 175 additions and 1 deletions

View File

@@ -1,3 +1,3 @@
FROM nginx:alpine FROM nginx:alpine
COPY index.html admin.html reset-password.html /usr/share/nginx/html/ COPY index.html admin.html reset-password.html analysis.html /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf

164
frontend/analysis.html Normal file
View File

@@ -0,0 +1,164 @@
<!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="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.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);border-bottom:1px solid var(--border);position:sticky;top:0;z-index:40}
.header-inner{max-width:1120px;margin:0 auto;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:17px;font-weight:600}
.nav-tabs{display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:var(--radius-xs);padding:3px}
.nav-tab{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;color:var(--text2);text-decoration:none;transition:all .2s;cursor:pointer}
.nav-tab:hover{color:var(--text)}
.nav-tab.active{background:#fff;color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08)}
.header-right{display:flex;align-items:center;gap:4px}
.header-right a{color:var(--text2);text-decoration:none;font-size:12px;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s}
.header-right a:hover{background:rgba(0,0,0,.04);color:var(--text)}
.header-right .logout-link{color:var(--red)}
.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">
<h1>债务统计</h1>
<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>

View File

@@ -30,6 +30,12 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
.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;padding:0} .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;padding:0}
.header-inner{max-width:1120px;margin:0 auto;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between} .header-inner{max-width:1120px;margin:0 auto;padding:0 24px;height:52px;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px} .header h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px}
/* NAV TABS */
.nav-tabs{display:flex;gap:4px;background:rgba(0,0,0,.04);border-radius:var(--radius-xs);padding:3px}
.nav-tab{padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;color:var(--text2);text-decoration:none;transition:all .2s;cursor:pointer}
.nav-tab:hover{color:var(--text)}
.nav-tab.active{background:#fff;color:var(--text);box-shadow:0 1px 3px rgba(0,0,0,.08)}
.header-right{display:flex;align-items:center;gap:4px} .header-right{display:flex;align-items:center;gap:4px}
.header-right a,.hdr-btn{color:var(--text2);text-decoration:none;font-size:12px;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s;background:transparent;border:none;cursor:pointer;font-family:inherit} .header-right a,.hdr-btn{color:var(--text2);text-decoration:none;font-size:12px;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s;background:transparent;border:none;cursor:pointer;font-family:inherit}
.header-right a:hover,.hdr-btn:hover{background:rgba(0,0,0,.04);color:var(--text)} .header-right a:hover,.hdr-btn:hover{background:rgba(0,0,0,.04);color:var(--text)}
@@ -322,6 +328,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
<div class="header"> <div class="header">
<div class="header-inner"> <div class="header-inner">
<h1>债务统计</h1> <h1>债务统计</h1>
<div class="nav-tabs">
<a href="/" class="nav-tab active">首页</a>
<a href="/analysis.html" class="nav-tab">分析</a>
</div>
<div class="header-right"> <div class="header-right">
<div class="export-wrap" style="position:relative"> <div class="export-wrap" style="position:relative">
<div class="notif-bell" onclick="toggleNotifDropdown(event)"> <div class="notif-bell" onclick="toggleNotifDropdown(event)">