Files
debt-manager/frontend/index.html
OP 00d1b96415 fix: 导出数据按用户权限过滤
- 普通用户只能导出自己的数据
- 管理员可以导出所有用户数据
- 添加 getVisibleLoans() 函数统一过滤逻辑
2026-06-29 00:50:20 +08:00

834 lines
76 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<style>
:root {
--bg: #f5f5f7;
--card: rgba(255,255,255,.8);
--glass: rgba(255,255,255,.72);
--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","SF Pro Text","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}
/* HEADER */
.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 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}
.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}
.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,.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 .admin-link{color:var(--blue)}
.header-right .logout-link{color:var(--red)}
.user-info{color:var(--text);font-size:13px;font-weight:500;cursor:pointer;padding:6px 12px;border-radius:var(--radius-xs);transition:all .2s}
.user-info:hover{background:rgba(0,0,0,.04)}
.notif-wrap{position:relative}
.notif-bell{cursor:pointer;padding:4px 8px;border-radius:var(--radius-xs);transition:all .2s;color:var(--text2)}
.notif-bell:hover{background:rgba(0,0,0,.04);color:var(--text)}
.notif-badge{position:absolute;top:2px;right:2px;min-width:14px;height:14px;background:var(--red);color:#fff;border-radius:7px;font-size:9px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 3px}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:rgba(255,255,255,.96);backdrop-filter:blur(24px);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
.notif-dropdown.show{display:block}
.notif-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.notif-header h4{font-size:14px;font-weight:600;color:var(--text)}
.notif-header .mark-all{font-size:12px;color:var(--blue);cursor:pointer;background:none;border:none;font-family:inherit}
.notif-list{overflow-y:auto;max-height:380px}
.notif-item{padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
.notif-item:hover{background:rgba(0,0,0,.02)}
.notif-item.unread{background:rgba(0,113,227,.03)}
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.notif-icon.repayment_due{background:rgba(0,113,227,.08)}
.notif-icon.repayment_overdue{background:rgba(255,59,48,.08)}
.notif-icon.system{background:rgba(175,82,222,.08)}
.notif-icon.account{background:rgba(52,199,89,.08)}
.notif-item .notif-content{flex:1;min-width:0}
.notif-item .notif-title{font-size:13px;font-weight:500;color:var(--text);margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-msg{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-time{font-size:11px;color:var(--text3);white-space:nowrap}
.notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
.export-wrap{position:relative}
.export-menu{display:none;position:absolute;right:0;top:calc(100% + 4px);background:rgba(255,255,255,.95);backdrop-filter:blur(20px);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 8px 32px rgba(0,0,0,.12);min-width:160px;z-index:50;overflow:hidden}
.export-menu.show{display:block}
.export-menu a{display:block;padding:10px 18px;font-size:13px;color:var(--text);text-decoration:none;transition:background .15s}
.export-menu a:hover{background:rgba(0,0,0,.04)}
.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)}
/* MAIN */
.main{max-width:1120px;margin:0 auto;padding:24px;display:grid;grid-template-columns:320px 1fr;gap:20px}
@media(max-width:900px){.main{grid-template-columns:1fr}}
/* PANEL */
.panel{background:var(--card);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:var(--radius);border:1px solid var(--border);overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.04)}
.panel-title{padding:16px 20px;border-bottom:1px solid var(--border);font-size:14px;font-weight:600;color:var(--text);display:flex;align-items:center;justify-content:space-between;background:linear-gradient(180deg,rgba(245,245,247,.8) 0%,rgba(255,255,255,0) 100%);position:relative}
.panel-title::before{content:"";position:absolute;left:0;top:50%;transform:translateY(-50%);width:3px;height:18px;background:var(--blue);border-radius:2px}
.panel-title .title-text{display:flex;align-items:center;gap:8px;padding-left:8px}
.panel-title .title-badge{font-size:10px;padding:2px 8px;border-radius:4px;background:rgba(0,113,227,.08);color:var(--blue);font-weight:500}
.panel-body{padding:16px 20px}
.panel+.panel{margin-top:16px}
/* FORM */
.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px 16px;padding:4px 0}
.form-grid .form-row{margin-bottom:0;min-width:0}
.form-row label{display:block;font-size:11px;color:var(--text2);margin-bottom:4px;font-weight:500}
.form-row input,.form-row select{width:100%;min-width:0;padding:8px 12px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:13px;outline:none;transition:all .2s;background:#fff;color:var(--text);-webkit-appearance:none}
.form-row input:focus,.form-row select:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.calc-hint{font-size:11px;color:var(--blue);margin-top:3px;min-height:16px}
/* BUTTONS */
.btn-row{display:flex;gap:10px;margin-top:16px}
.btn{flex:1;padding:10px 0;border:none;border-radius:var(--radius-xs);font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;font-family:inherit}
.btn-main{background:var(--blue);color:#fff}
.btn-main:hover{background:#0077ed}
.btn-ghost{background:#fff;color:var(--text2);border:1px solid #d2d2d7}
.btn-ghost:hover{background:#f5f5f7;border-color:#bbb}
/* LOAN LIST */
.loan-list{overflow-y:auto}
.loan-group{border-bottom:1px solid var(--border)}
.loan-group:last-child{border-bottom:none}
.loan-group-header{padding:12px 20px;display:flex;justify-content:space-between;align-items:center;cursor:pointer;user-select:none;transition:background .2s}
.loan-group-header:hover{background:rgba(0,0,0,.02)}
.loan-group-header .arrow{font-size:10px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1);margin-right:8px;display:inline-block}
.loan-group-header .arrow.open{transform:rotate(90deg)}
.loan-group-header .gname{font-size:15px;font-weight:600;color:var(--text)}
.loan-group-header .gsummary{font-size:12px;color:var(--text3)}
.loan-group-body{display:none}
.loan-group-body.open{display:block}
.loan-item{padding:10px 20px 10px 36px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;justify-content:space-between;align-items:center}
.loan-item:last-child{border-bottom:none}
.loan-item:hover{background:rgba(0,0,0,.02)}
.loan-item.active{background:rgba(0,113,227,.04);border-left:3px solid var(--blue)}
.loan-item .iname{font-size:13px;font-weight:500;color:var(--text)}
.loan-item .isub{font-size:11px;color:var(--text3);margin-top:2px}
.loan-item .iamt{font-size:13px;font-weight:600;color:var(--text)}
.loan-item.done-item{background:rgba(52,199,89,.04)}
.loan-item.done-item .iname,.loan-item.done-item .isub{text-decoration:line-through;color:var(--text3)}
.loan-item.done-item .iamt{text-decoration:line-through;color:var(--text3)}
.loan-actions-popup{display:flex;gap:6px;padding:8px 20px;border-top:1px solid var(--border);background:rgba(245,245,247,.5)}
.loan-actions-popup .act-btn{padding:5px 14px;border-radius:8px;font-size:12px;font-weight:500;cursor:pointer;border:none;transition:all .2s;font-family:inherit}
.act-btn-edit{background:rgba(0,113,227,.08);color:var(--blue)}
.act-btn-edit:hover{background:rgba(0,113,227,.15)}
.act-btn-pay{background:rgba(52,199,89,.08);color:var(--green)}
.act-btn-pay:hover{background:rgba(52,199,89,.15)}
.act-btn-del{background:rgba(255,59,48,.08);color:var(--red)}
.act-btn-del:hover{background:rgba(255,59,48,.15)}
.quick-panel{padding:8px 20px 12px;border-top:1px solid var(--border);background:rgba(245,245,247,.5)}
.quick-panel .qp-title{font-size:11px;color:var(--text3);margin-bottom:6px;font-weight:500}
.quick-panel .qp-row{display:flex;justify-content:space-between;align-items:center;padding:4px 0;font-size:12px}
.quick-panel .qp-row .qp-info{color:var(--text2)}
.quick-panel .qp-row .qp-amt{color:var(--text);font-weight:500;font-variant-numeric:tabular-nums}
.quick-panel .qp-row .qp-btn{padding:3px 10px;border:none;border-radius:6px;font-size:11px;cursor:pointer;font-weight:500;transition:all .2s;font-family:inherit}
.qp-btn-done{background:#34c759!important;color:#fff!important}
.qp-btn-pay{background:var(--blue);color:#fff}
.qp-btn-pay:hover{background:#0077ed}
.badge-done{display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--green);color:#fff;font-weight:600;margin-left:6px}
.empty-tip{padding:32px;text-align:center;color:var(--text3);font-size:13px}
/* STATS */
.stats{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
.stat-block{margin-bottom:0}
.stat-card{background:var(--card);border-radius:var(--radius-sm);padding:16px;text-align:center;border:1px solid var(--border);transition:all .2s;position:relative}
.stat-card .lbl{font-size:11px;color:var(--text3);margin-bottom:4px;font-weight:500;letter-spacing:.3px}
.stat-card .val{font-size:18px;font-weight:700;color:var(--text)}
.stat-card .val.blue{color:var(--blue)}
.stat-card .val.red{color:var(--red)}
.stat-card .val.green{color:var(--green)}
.stat-card.clickable{cursor:pointer}
.stat-card.clickable:hover{box-shadow:0 2px 12px rgba(0,0,0,.06);transform:translateY(-1px)}
.stat-arrow{position:absolute;top:8px;right:10px;font-size:8px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1)}
.stat-arrow.open{transform:rotate(90deg)}
.stats-detail-wrap{background:rgba(245,245,247,.5);border:1px solid var(--border);border-top:none;border-radius:0 0 var(--radius-sm) var(--radius-sm);padding:10px 16px}
.stat-sub{display:flex;justify-content:space-between;font-size:12px;padding:5px 0;border-bottom:1px solid var(--border)}
.stat-sub:last-child{border-bottom:none}
.stat-sub span:first-child{color:var(--text2)}
.stat-sub .stat-sub-val{font-weight:600;color:var(--text)}
@media(max-width:600px){.stats{grid-template-columns:repeat(2,1fr)}}
/* TAGS */
.tag{display:inline-block;padding:2px 7px;border-radius:6px;font-size:10px;font-weight:600}
.tag-e{background:rgba(0,113,227,.08);color:var(--blue)}
.tag-p{background:rgba(255,149,0,.08);color:var(--orange)}
.tag-i{background:rgba(175,82,222,.08);color:var(--purple)}
.tag-0{background:rgba(0,0,0,.04);color:var(--text3)}
/* RATE */
.rate-low{color:var(--green);font-weight:600}
.rate-mid{color:var(--orange);font-weight:600}
.rate-high{color:var(--red);font-weight:700}
/* MONTH PLAN */
.month-group{border-bottom:1px solid var(--border)}
.month-group:last-child{border-bottom:none}
.month-header{display:flex;justify-content:space-between;align-items:center;padding:12px 20px;cursor:pointer;user-select:none;transition:background .2s}
.month-header:hover{background:rgba(0,0,0,.02)}
.month-header .ym{font-size:14px;font-weight:600;color:var(--text)}
.month-header .total{font-size:15px;font-weight:700;color:var(--blue)}
.month-header .arrow{font-size:10px;color:var(--text3);transition:transform .3s cubic-bezier(.4,0,.2,1);display:inline-block}
.month-header .arrow.open{transform:rotate(90deg)}
.month-detail{display:none;padding:0 20px 12px}
.month-detail.show{display:block}
.loan-row{display:flex;justify-content:space-between;align-items:center;padding:8px 0;border-bottom:1px solid var(--border);cursor:pointer;font-size:13px;transition:all .15s}
.loan-row:last-child{border-bottom:none}
.loan-row:hover{background:rgba(0,0,0,.02);margin:0 -12px;padding:8px 12px;border-radius:var(--radius-xs)}
.loan-row .left{flex:1;min-width:0;display:flex;flex-direction:column}
.loan-row .borrower{font-size:13px;font-weight:500;color:var(--text)}
.loan-row .detail{font-size:11px;color:var(--text3);margin-top:2px}
.loan-row .right{flex-shrink:0;width:110px;text-align:right}
.loan-row .pay-amt{font-size:14px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums}
.loan-row .pay-date{font-size:11px;color:var(--text3);font-variant-numeric:tabular-nums}
.loan-row.paid{opacity:.45}
.loan-row.paid .borrower{text-decoration:line-through}
.paid-badge{display:inline-block;font-size:9px;padding:2px 6px;border-radius:4px;background:var(--green);color:#fff;margin-left:4px}
.loan-row .pay-check{width:22px;height:22px;border:2px solid #d2d2d7;border-radius:6px;cursor:pointer;flex-shrink:0;display:flex;align-items:center;justify-content:center;transition:all .2s;margin-left:12px}
.loan-row .pay-check:hover{border-color:var(--blue);background:rgba(0,113,227,.06)}
.loan-row .pay-check.checked{background:var(--blue);border-color:var(--blue)}
.loan-row .pay-check.checked::after{content:"✓";color:#fff;font-size:12px;font-weight:700}
.btn-pay-all{padding:6px 16px;border:1px solid var(--blue);background:transparent;color:var(--blue);border-radius:var(--radius-xs);font-size:12px;cursor:pointer;transition:all .2s;white-space:nowrap;font-weight:500;font-family:inherit}
.btn-pay-all:hover{background:var(--blue);color:#fff}
/* MODALS */
.modal-mask{display:none;position:fixed;inset:0;background:rgba(0,0,0,.3);z-index:100;align-items:center;justify-content:center;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
.modal-mask.show{display:flex}
.modal{background:#fff;border-radius:var(--radius);padding:28px;max-width:420px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.15);animation:fadeIn .25s ease-out}
@keyframes fadeIn{from{opacity:0;transform:scale(.96) translateY(8px)}to{opacity:1;transform:scale(1) translateY(0)}}
.modal h3{margin-bottom:18px;font-size:17px;color:var(--text);font-weight:600}
.modal .btn-row{margin-top:18px}
/* AUTH */
.auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)}
.auth-card{background:rgba(255,255,255,.72);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);border-radius:20px;padding:48px 44px;width:400px;border:1px solid var(--border);box-shadow:0 16px 48px rgba(0,0,0,.08)}
.auth-card h2{text-align:center;margin-bottom:32px;color:var(--text);font-size:22px;font-weight:700;letter-spacing:-.3px}
.auth-card .form-row{margin-bottom:18px}
.auth-card .form-row label{font-size:13px;font-weight:500}
.auth-card .form-row input{padding:12px 16px;font-size:15px}
.auth-card .links{text-align:center;margin-top:18px}
.auth-card .links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
.auth-card .links a:hover{text-decoration:underline}
.auth-card .error-msg{display:none;font-size:13px;margin-top:0;text-align:center;padding:12px 18px;border-radius:var(--radius-sm);background:rgba(255,59,48,.08);backdrop-filter:saturate(180%) blur(20px);-webkit-backdrop-filter:saturate(180%) blur(20px);color:var(--red);border:1px solid rgba(255,59,48,.15);line-height:1.5;align-items:center;justify-content:center;gap:8px;box-shadow:0 8px 32px rgba(255,59,48,.1);animation:fadeSlideIn .3s ease-out}
.auth-card .error-msg.show{display:flex}
@keyframes fadeSlideIn{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}}
.auth-card .success-msg{color:var(--green);font-size:13px;margin-top:12px;text-align:center;background:rgba(52,199,89,.06);padding:12px;border-radius:var(--radius-xs)}
/* DROPDOWN */
.export-wrap{position:relative;display:inline-block}
.export-menu{display:none;position:absolute;right:0;top:calc(100% + 4px);background:rgba(255,255,255,.95);backdrop-filter:blur(20px);border:1px solid var(--border);border-radius:var(--radius-sm);box-shadow:0 8px 32px rgba(0,0,0,.12);min-width:180px;z-index:50;overflow:hidden}
.export-menu.show{display:block}
.export-menu a{display:block;padding:10px 18px;font-size:13px;color:var(--text);text-decoration:none;transition:background .15s}
.export-menu a:hover{background:rgba(0,0,0,.04)}
#loginPage,#registerPage{display:none}
/* TOAST */
.toast-container{position:fixed;top:20px;left:50%;transform:translateX(-50%);z-index:9999;display:flex;flex-direction:column;align-items:center;gap:8px;pointer-events:none}
.toast{padding:12px 24px;border-radius:var(--radius-sm);font-size:13px;font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,.12);animation:toastIn .3s ease-out;pointer-events:auto;max-width:400px;text-align:center}
.toast.info{background:#1d1d1f;color:#fff}
.toast.success{background:#fff;color:var(--green);border:1px solid rgba(52,199,89,.2)}
.toast.warning{background:#fff;color:var(--orange);border:1px solid rgba(255,149,0,.2)}
.toast.error{background:#fff;color:var(--red);border:1px solid rgba(255,59,48,.2)}
@keyframes toastIn{from{opacity:0;transform:translateY(-12px)}to{opacity:1;transform:translateY(0)}}
/* NOTIFICATION BANNER */
.notif-banner{background:linear-gradient(135deg,#ff9500,#ff3b30);color:#fff;padding:10px 24px;text-align:center;font-size:13px;font-weight:500;cursor:pointer;animation:slideDown .3s ease-out}
.notif-banner:hover{filter:brightness(1.05)}
@keyframes slideDown{from{max-height:0;opacity:0}to{max-height:60px;opacity:1}}
/* NOTIFICATION CENTER */
.notif-bell{position:relative;cursor:pointer;padding:6px 10px;border-radius:var(--radius-xs);transition:all .2s}
.notif-bell:hover{background:rgba(0,0,0,.04)}
.notif-bell svg{width:18px;height:18px;fill:var(--text2);transition:fill .2s}
.notif-bell:hover svg{fill:var(--text)}
.notif-badge{position:absolute;top:4px;right:4px;min-width:16px;height:16px;background:var(--red);color:#fff;border-radius:8px;font-size:10px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #fff;animation:badgePop .3s ease-out}
@keyframes badgePop{from{transform:scale(0)}to{transform:scale(1)}}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:rgba(255,255,255,.96);backdrop-filter:blur(24px);-webkit-backdrop-filter:blur(24px);border:1px solid var(--border);border-radius:var(--radius);box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
.notif-dropdown.show{display:block}
.notif-header{padding:14px 16px;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.notif-header h4{font-size:14px;font-weight:600;color:var(--text)}
.notif-header .mark-all{font-size:12px;color:var(--blue);cursor:pointer;background:none;border:none;font-family:inherit}
.notif-header .mark-all:hover{text-decoration:underline}
.notif-list{overflow-y:auto;max-height:380px}
.notif-item{padding:12px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
.notif-item:hover{background:rgba(0,0,0,.02)}
.notif-item.unread{background:rgba(0,113,227,.03)}
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.notif-icon.repayment_due{background:rgba(0,113,227,.08)}
.notif-icon.repayment_overdue{background:rgba(255,59,48,.08)}
.notif-icon.system{background:rgba(175,82,222,.08)}
.notif-icon.account{background:rgba(52,199,89,.08)}
.notif-item .notif-content{flex:1;min-width:0}
.notif-item .notif-title{font-size:13px;font-weight:500;color:var(--text);margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-msg{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-time{font-size:11px;color:var(--text3);white-space:nowrap}
.notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
.profile-section{padding:4px 0}
.profile-section .form-row{margin-bottom:14px}
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
</style>
</head>
<body>
<div class="toast-container" id="toastContainer"></div>
<div id="notifBanner" style="display:none"></div>
<div id="loginPage" class="auth-page">
<div class="auth-card">
<h2>债务管理系统</h2>
<div class="form-row"><label>账号</label><input type="text" id="loginEmail" placeholder="请输入账号"></div>
<div class="form-row"><label>密码</label><input type="password" id="loginPwd" placeholder="请输入密码" onkeydown="if(event.key==='Enter')doLogin()"></div>
<div id="loginError" class="error-msg"><span></span><span id="loginErrorText"></span></div>
<div class="btn-row"><button class="btn btn-main" onclick="doLogin()">登录</button></div>
<div class="links"><a href="#" onclick="showRegister()">注册新账户</a> <span style="color:var(--text3);margin:0 6px">|</span> <a href="#" onclick="showForgotStep1()">找回密码</a></div>
</div>
</div>
<div class="modal-mask" id="forgotStep1">
<div class="modal">
<h3>找回密码</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">请输入账号或绑定的邮箱,我们将发送验证码。</p>
<div class="form-row"><label>账号 / 邮箱</label><input type="text" id="fpAccount" placeholder="请输入账号或邮箱"></div>
<div id="fpStep1Error" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
<button class="btn btn-main" onclick="doForgotStep1()">发送验证码</button>
</div>
</div>
</div>
<div class="modal-mask" id="forgotStep2">
<div class="modal">
<h3>输入验证码</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">验证码已发送至绑定邮箱,请输入 6 位验证码。</p>
<div class="form-row"><label>验证码</label><input type="text" id="fpCode" placeholder="6 位数字验证码" maxlength="6" style="letter-spacing:4px;text-align:center;font-size:18px"></div>
<div id="fpStep2Error" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
<button class="btn btn-main" onclick="doForgotStep2()">验证</button>
</div>
</div>
</div>
<div class="modal-mask" id="forgotStep3">
<div class="modal">
<h3>重置密码</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:14px">验证码通过,请设置新密码。</p>
<div class="form-row"><label>新密码</label><input type="password" id="fpNewPwd" placeholder="请输入新密码"></div>
<div class="form-row"><label>确认新密码</label><input type="password" id="fpNewPwd2" placeholder="请再次输入新密码"></div>
<div id="fpStep3Error" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeForgotModals()">取消</button>
<button class="btn btn-main" onclick="doForgotStep3()">确认重置</button>
</div>
</div>
</div>
<div id="registerPage" class="auth-page">
<div class="auth-card">
<h2>注册账户</h2>
<div id="regSuccess" class="success-msg" style="display:none"></div>
<div id="regForm">
<div class="form-row"><label>账号</label><input type="text" id="regEmail" placeholder="请输入账号"></div>
<div class="form-row"><label>昵称</label><input type="text" id="regNick" placeholder="请输入昵称"></div>
<div class="form-row"><label>密码</label><input type="password" id="regPwd" placeholder="请输入密码"></div>
<div class="form-row"><label>确认密码</label><input type="password" id="regPwd2" placeholder="请再次输入密码"></div>
<div id="regError" class="error-msg"></div>
<div class="btn-row"><button class="btn btn-main" onclick="doRegister()">注册</button></div>
</div>
<div class="links"><a href="#" onclick="showLogin()">已有账户?去登录</a></div>
</div>
</div>
<div id="appPage">
<div class="header">
<div class="header-inner">
<div style="display:flex;align-items:center;gap:16px">
<h1>债务统计</h1>
<div class="nav-tabs">
<a href="/" class="nav-tab active">首页</a>
<a href="/analysis.html" class="nav-tab">分析</a>
</div>
</div>
<div class="header-right">
<div class="notif-wrap">
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
</div>
<div class="notif-dropdown" id="notifDropdown">
<div class="notif-header"><h4>通知</h4><button class="mark-all" onclick="markAllNotifRead()">全部已读</button></div>
<div class="notif-list" id="notifList"><div class="notif-empty">暂无通知</div></div>
</div>
</div>
<div class="export-wrap">
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
<div class="export-menu" id="userMenu">
<a href="#" onclick="showProfile()">个人中心</a>
<a href="#" onclick="showChangePwd(event)">修改密码</a>
</div>
</div>
<a href="/admin.html" class="admin-link" id="adminLink" style="display:none">管理</a>
<div class="export-wrap">
<button class="hdr-btn" onclick="toggleExport(event)">导出</button>
<div class="export-menu" id="exportMenu">
<a href="#" onclick="exportCSV(event)">导出 CSV</a>
<a href="#" onclick="exportJSON(event)">导出 JSON</a>
<a href="#" onclick="exportHTML(event)">导出 HTML</a>
</div>
</div>
<div class="export-wrap">
<button class="hdr-btn" onclick="toggleImport(event)">导入</button>
<div class="export-menu" id="importMenu">
<a href="#" onclick="downloadTemplate(event)">下载模板</a>
<a href="#" onclick="showImportModal(event)">导入 CSV</a>
</div>
</div>
<a href="#" class="logout-link" onclick="doLogout()">退出</a>
</div>
</div>
</div>
<div class="main">
<div style="display:flex;flex-direction:column;gap:16px">
<div class="panel">
<div class="panel-title"><span class="title-text">📝 新增借款</span><span class="title-badge" id="formTitle">编辑中</span></div>
<div class="panel-body">
<div class="form-grid">
<div class="form-row"><label>借款日期</label><input type="date" id="fDate"></div>
<div class="form-row"><label>债权人</label><input type="text" id="fName" placeholder="债权人名称"></div>
<div class="form-row"><label>借款金额(元)</label><input type="number" id="fAmt" placeholder="0.00" step="0.01" min="0"></div>
<div class="form-row"><label>用途</label><input type="text" id="fPurpose" placeholder="如:装修、教育"></div>
<div class="form-row"><label>还款方式</label><select id="fMethod"><option value="e">等额本息</option><option value="p">等额本金</option><option value="i">先息后本</option></select></div>
<div class="form-row"><label>分期数(月)</label><input type="number" id="fN" placeholder="12" min="1" max="360"></div>
<div class="form-row"><label>年化率(%</label><input type="number" id="fRate" placeholder="利率" step="0.01" min="0"><div class="calc-hint" id="rateHint"></div></div>
<div class="form-row"><label>总利息(元)</label><input type="number" id="fTotalInt" placeholder="总利息" step="0.01" min="0"><div class="calc-hint" id="intHint"></div></div>
</div>
<div class="btn-row">
<button class="btn btn-main" id="btnSubmit" onclick="submitLoan()">添加</button>
<button class="btn btn-ghost" onclick="clearForm()">清空</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title"><span class="title-text">📋 借款列表</span> <span style="font-size:11px;color:var(--text3);font-weight:400;cursor:pointer;display:flex;gap:12px"><span onclick="toggleHidePaid()"><span id="hidePaidIcon">🙈</span> 显示已还清</span><span onclick="toggleHideOthers()"><span id="hideOthersIcon">🙈</span> 显示他人</span></span></div>
<div class="loan-list" id="loanList"><div class="empty-tip">暂无记录</div></div>
</div>
</div>
<div id="rightPanel" style="display:none">
<div class="panel" style="margin-bottom:16px">
<div class="panel-title"><span class="title-text">📊 汇总概览</span></div>
<div class="panel-body"><div class="stats" id="statsGrid"></div></div>
</div>
<div class="panel">
<div class="panel-title"><span class="title-text">📅 月度还款计划</span> <span style="font-size:11px;color:var(--text3);font-weight:400;margin-left:auto;display:flex;gap:14px;align-items:center"><span style="cursor:pointer" onclick="toggleHidePaidMonths()"><span id="hidePaidMonthsIcon">🙈</span> 显示已还完</span><span style="cursor:pointer" onclick="toggleHideOthers()"><span id="hideOthersIcon2">🙈</span> 显示他人</span><span>点击标记已还</span></span></div>
<div id="monthList"></div>
</div>
</div>
</div>
</div>
<div class="modal-mask" id="delModal"><div class="modal"><h3>确认删除</h3><p style="font-size:13px;color:var(--text2);line-height:1.6">此操作不可撤销,删除后该借款记录将永久移除。</p><div class="btn-row"><button class="btn btn-ghost" onclick="closeDel()">取消</button><button class="btn btn-main" style="background:var(--red)" onclick="confirmDel()">确认删除</button></div></div></div>
<div class="modal-mask" id="importModal"><div class="modal" style="max-width:500px"><h3>导入 CSV 数据</h3><p style="font-size:12px;color:var(--text2);margin-bottom:14px">选择 CSV 文件导入借款数据,可先下载模板再修改。</p><div class="form-row"><input type="file" id="importFile" accept=".csv" style="font-size:13px"></div><div id="importError" class="error-msg" style="display:none"></div><div id="importSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeImportModal()">取消</button><button class="btn btn-main" onclick="doImport()">导入</button></div></div></div>
<div class="modal-mask" id="changeNickModal"><div class="modal"><h3>修改昵称</h3><div class="form-row"><label>新昵称</label><input type="text" id="newNick" placeholder="请输入新昵称"></div><div id="nickError" class="error-msg" style="display:none"></div><div id="nickSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeChangeNick()">取消</button><button class="btn btn-main" onclick="doChangeNick()">确认修改</button></div></div></div>
<div class="modal-mask" id="changePwdModal"><div class="modal"><h3>修改密码</h3><div class="form-row"><label>原密码</label><input type="password" id="oldPwd" placeholder="请输入原密码"></div><div class="form-row"><label>新密码</label><input type="password" id="newPwd" placeholder="请输入新密码"></div><div class="form-row"><label>确认新密码</label><input type="password" id="newPwd2" placeholder="请再次输入新密码"></div><div id="pwdError" class="error-msg" style="display:none"></div><div id="pwdSuccess" class="success-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-ghost" onclick="closeChangePwd()">取消</button><button class="btn btn-main" onclick="doChangePwd()">确认修改</button></div></div></div>
<div class="modal-mask" id="emailModal"><div class="modal"><h3>绑定邮箱</h3><p style="font-size:13px;color:var(--text2);line-height:1.6;margin-bottom:14px">首次使用请绑定邮箱,用于接收还款提醒和密码找回。</p><div class="form-row"><label>邮箱地址</label><input type="email" id="bindEmail" placeholder="请输入有效邮箱"></div><div id="bindEmailError" class="error-msg" style="display:none"></div><div class="btn-row"><button class="btn btn-main" onclick="doBindEmail()">确认绑定</button></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=[];
let delTarget=null;
let openMonths=new Set();
let openGroups=new Set();
let editId=null;
let hidePaidItems=true;
let hidePaidMonths=true;
let hideOthers=true;
let quickPayLoanId=null;
let actionLoanId=null;
const $=id=>document.getElementById(id);function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
const today=()=>new Date().toISOString().slice(0,10);
const ymOf=d=>d.slice(0,7);
const methodLabel=m=>m==='e'?'等额本息':m==='p'?'等额本金':'先息后本';
const methodTag=m=>m==='e'?'tag-e':m==='p'?'tag-p':'tag-i';
function showToast(msg,type='info',dur=3000){const c=$('toastContainer');const t=document.createElement('div');t.className='toast '+type;t.textContent=msg;c.appendChild(t);setTimeout(()=>{t.style.opacity='0';t.style.transition='opacity .3s';setTimeout(()=>t.remove(),300);},dur);}
let notifPage=1;
function notifTypeIcon(type){const map={repayment_due:'📅',repayment_overdue:'⚠️',system:'📢',account:'👤'};return map[type]||'📩';}
function notifTypeLabel(type){const map={repayment_due:'还款提醒',repayment_overdue:'逾期提醒',system:'系统消息',account:'账户通知'};return map[type]||'通知';}
function timeAgo(dt){const diff=(Date.now()-new Date(dt).getTime())/1000;if(diff<60)return '刚刚';if(diff<3600)return Math.floor(diff/60)+'分钟前';if(diff<86400)return Math.floor(diff/3600)+'小时前';return Math.floor(diff/86400)+'天前';}
function toggleNotifDropdown(e){e.stopPropagation();const dd=$('notifDropdown');dd.classList.toggle('show');if(dd.classList.contains('show'))loadNotifications();}
document.addEventListener('click',function(){$('notifDropdown')?.classList.remove('show');});
async function loadNotifications(){const res=await apiFetch('/notifications?size=20');if(!res||!res.ok)return;const data=await res.json();renderNotifBadge(data.unread);renderNotifList(data.items);}
function renderNotifBadge(unread){const badge=$('notifBadge');if(unread>0){badge.style.display='';badge.textContent=unread>99?'99+':unread;}else{badge.style.display='none';}}
function renderNotifList(items){const el=$('notifList');if(!items.length){el.innerHTML='<div class="notif-empty">暂无通知</div>';return;}el.innerHTML=items.map(n=>`<div class="notif-item${n.is_read===0?' unread':''}" onclick="readNotif(${n.id},'${n.type}',${n.related_debt_id||0})"><div class="notif-icon ${n.type}">${notifTypeIcon(n.type)}</div><div class="notif-content"><div class="notif-title">${escapeHtml(n.title)}</div><div class="notif-msg">${escapeHtml(n.message)}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join('');}
async function readNotif(id,type,debtId){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();if(debtId&&(type==='repayment_due'||type==='repayment_overdue')){$('notifDropdown').classList.remove('show');}}
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
async function loadNotifBanner(){const res=await apiFetch('/notifications?unread_only=true&size=5');if(!res||!res.ok)return;const data=await res.json();const overdue=data.items.find(n=>n.type==='repayment_overdue');const due=data.items.find(n=>n.type==='repayment_due');const banner=overdue||due;if(banner){$('notifBanner').style.display='';$('notifBanner').className='notif-banner';$('notifBanner').textContent=banner.title;$('notifBanner').onclick=()=>{$('notifBanner').style.display='none';toggleNotifDropdown(new Event('click'));};}}
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;
}
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
function showProfile(){
$('userMenu').classList.remove('show');
$('profileAccount').value=currentUser.account;
$('profileNickname').value=currentUser.nickname||'';
$('profileEmail').value=currentUser.real_email||'';
$('emailCodeRow').style.display='none';
$('profileEmailCode').value='';
$('profileError').style.display='none';
$('profileSuccess').style.display='none';
$('profileModal').classList.add('show');
}
function closeProfile(){$('profileModal').classList.remove('show');}
let emailVerifySent=false;
async function sendEmailVerify(){
const email=$('profileEmail').value.trim();
if(!email||!email.includes('@')){showProfileError('请输入有效邮箱');return;}
$('profileError').style.display='none';
const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})});
if(res&&res.ok){
emailVerifySent=true;
$('emailCodeRow').style.display='';
$('profileSuccess').style.display='';$('profileSuccess').textContent='验证码已发送到当前邮箱';
}else{
const d=await res.json().catch(()=>({}));
showProfileError(d.detail||'发送失败');
}
}
function showProfileError(msg){$('profileError').textContent=msg;$('profileError').style.display='';}
function hideProfileError(){$('profileError').style.display='none';}
async function saveProfile(){
const nick=$('profileNickname').value.trim();
const email=$('profileEmail').value.trim();
const code=$('profileEmailCode').value.trim();
hideProfileError();
$('profileSuccess').style.display='none';
// Update nickname
if(nick!==currentUser.nickname){
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});
if(res&&res.ok){currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'昵称修改失败');return;}
}
// Update email if changed and code provided
if(email&&email!==currentUser.real_email){
if(!code){showProfileError('请输入邮箱验证码');$('emailCodeRow').style.display='';return;}
const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})});
if(res&&res.ok){currentUser.real_email=email;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'邮箱修改失败');return;}
}
$('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功';
setTimeout(()=>{closeProfile();},1000);
}
async function checkAuth(){
if(!token){showLogin();return false;}
const res=await apiFetch('/auth/profile');
if(!res||!res.ok){showLogin();return false;}
currentUser=await res.json();
$('userInfo').textContent=currentUser.nickname||currentUser.account;
if(currentUser.role==='admin')$('adminLink').style.display='';
loadNotifBanner();
showApp();
if(!currentUser.real_email)$('emailModal').classList.add('show');
return true;
}
function showLogin(){$('loginPage').style.display='flex';$('registerPage').style.display='none';$('appPage').style.display='none';}
function showRegister(){$('loginPage').style.display='none';$('registerPage').style.display='flex';$('appPage').style.display='none';}
function showApp(){$('loginPage').style.display='none';$('registerPage').style.display='none';$('appPage').style.display='';}
function showLoginError(msg){$('loginErrorText').textContent=msg;$('loginError').classList.add('show');}
function hideLoginError(){$('loginError').classList.remove('show');$('loginErrorText').textContent='';}
let loginCountdown=null;
function startLoginCountdown(seconds){
if(loginCountdown)clearInterval(loginCountdown);
let remaining=seconds;
const btn=document.querySelector('#loginPage .btn-main');
btn.disabled=true;btn.style.opacity='.5';
function tick(){
const m=Math.floor(remaining/60),s=remaining%60;
showLoginError(`登录失败次数过多,请 ${m}:${String(s).padStart(2,'0')} 后再试`);
if(remaining<=0){clearInterval(loginCountdown);loginCountdown=null;btn.disabled=false;btn.style.opacity='';hideLoginError();}
remaining--;
}
tick();
loginCountdown=setInterval(tick,1000);
}
async function doLogin(){
const account=$('loginEmail').value.trim(),pwd=$('loginPwd').value;
if(!account||!pwd)return showLoginError('请输入账号和密码');
hideLoginError();
try{const res=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd})});const data=await res.json();if(!res.ok){if(res.status===429&&data.retry_after){startLoginCountdown(data.retry_after);}else{showLoginError(data.detail||'登录失败');}return;}token=data.access_token;refreshToken=data.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);await checkAuth();await loadLoans();}catch(e){showLoginError('网络连接异常,请稍后重试');}
}
async function doRegister(){
const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value;
if(!account||!pwd)return $('regError').textContent='请填写账号和密码';
if(pwd!==pwd2)return $('regError').textContent='两次输入的密码不一致';
$('regError').textContent='';
try{const res=await fetch(API+'/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account,password:pwd,nickname:nick})});const data=await res.json();if(!res.ok){$('regError').textContent=data.detail||'注册失败';return;}$('regForm').style.display='none';$('regSuccess').style.display='';$('regSuccess').textContent=data.message||'注册成功,等待管理员审批';}catch(e){$('regError').textContent='网络连接异常,请稍后重试';}
}
function doLogout(){token='';refreshToken='';currentUser=null;localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');showLogin();}
async function loadLoans(){const res=await apiFetch('/loans');if(!res||!res.ok)return;loans=await res.json();renderAll();}
async function saveLoanToServer(loan){if(loan.id){const res=await apiFetch('/loans/'+loan.id,{method:'PUT',body:JSON.stringify(loan)});if(res&&res.ok)return await res.json();}else{const res=await apiFetch('/loans',{method:'POST',body:JSON.stringify(loan)});if(res&&res.ok)return await res.json();}return null;}
async function deleteLoanFromServer(id){const res=await apiFetch('/loans/'+id,{method:'DELETE'});return res&&res.ok;}
async function togglePaidOnServer(id,paid){const res=await apiFetch('/loans/'+id+'/toggle-paid',{method:'PATCH',body:JSON.stringify({paid})});if(res&&res.ok){const data=await res.json();return data.paid;}return null;}
$('fDate').value=today();
let syncingFields=false;
function calcTotalIntFromRate(a,r,n,m){if(!a||!r||!n)return 0;const mr=r/100/12;if(m==='e'){const mp=a*mr*Math.pow(1+mr,n)/(Math.pow(1+mr,n)-1);return mp*n-a;}if(m==='p')return a*mr*(n+1)/2;if(m==='i')return a*mr*n;return 0;}
function calcRateFromTotalInt(a,ti,n,m){if(!a||!ti||!n)return 0;if(m==='i')return ti*1200/(a*n);if(m==='p')return ti*2400/(a*(n+1));let lo=0,hi=1;for(let i=0;i<50;i++){if(calcTotalIntFromRate(a,hi,n,'e')>=ti)break;hi*=2;if(hi>100)return 0;}for(let i=0;i<100;i++){const mid=(lo+hi)/2;if(calcTotalIntFromRate(a,mid,n,'e')<ti)lo=mid;else hi=mid;}return(lo+hi)/2;}
function syncFromRate(){if(syncingFields)return;syncingFields=true;const a=+$('fAmt').value,r=+$('fRate').value,n=+$('fN').value,m=$('fMethod').value;if(a&&!isNaN(r)&&$('fRate').value!==''&&n){$('fTotalInt').value=calcTotalIntFromRate(a,r,n,m).toFixed(2);$('intHint').textContent='← 自动计算';$('rateHint').textContent='';}else{$('fTotalInt').value='';$('intHint').textContent='';}syncingFields=false;}
function syncFromTotalInt(){if(syncingFields)return;syncingFields=true;const a=+$('fAmt').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;if(a&&!isNaN(ti)&&$('fTotalInt').value!==''&&n){if(ti===0){$('fRate').value='0';$('rateHint').textContent='← 无息';$('intHint').textContent='';}else{const r=calcRateFromTotalInt(a,ti,n,m);$('fRate').value=r.toFixed(4);$('rateHint').textContent='← 自动计算';$('intHint').textContent='';}}else{$('fRate').value='';$('rateHint').textContent='';}syncingFields=false;}
let rateDebounce=null,intDebounce=null;
$('fRate').addEventListener('input',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);rateDebounce=setTimeout(syncFromRate,300);});
$('fRate').addEventListener('focus',function(){$('rateHint').textContent='输入后自动计算总利息';$('intHint').textContent='';});
$('fTotalInt').addEventListener('input',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);intDebounce=setTimeout(syncFromTotalInt,300);});
$('fTotalInt').addEventListener('focus',function(){$('intHint').textContent='输入后自动计算年利率';$('rateHint').textContent='';});
['fAmt','fN','fMethod'].forEach(id=>{$(id).addEventListener('change',function(){clearTimeout(rateDebounce);clearTimeout(intDebounce);const a=+$('fAmt').value,r=+$('fRate').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;if(a&&n&&$('fTotalInt').value!==''&&!isNaN(ti))syncFromTotalInt();else if(a&&n&&$('fRate').value!==''&&!isNaN(r))syncFromRate();});});
function calcSchedule(a,r,n,m,start){const mr=r/100/12,sd=new Date(start),list=[];if(m==='e'){const mp=mr===0?a/n:a*mr*Math.pow(1+mr,n)/(Math.pow(1+mr,n)-1);let rp=a;for(let i=0;i<n;i++){const it=rp*mr,pr=mp-it;rp-=pr;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(mp*100)/100,pr:Math.round(pr*100)/100,it:Math.round(it*100)/100,rp:Math.round(Math.max(0,rp)*100)/100});}}else if(m==='p'){const pp=a/n;let rp=a;for(let i=0;i<n;i++){const it=rp*mr,pay=pp+it;rp-=pp;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(pay*100)/100,pr:Math.round(pp*100)/100,it:Math.round(it*100)/100,rp:Math.round(Math.max(0,rp)*100)/100});}}else{const mi=a*mr;let rp=a;for(let i=0;i<n;i++){const isLast=i===n-1;const pr=isLast?a:0;const pay=mi+pr;const dd=new Date(sd);dd.setMonth(dd.getMonth()+i+1);list.push({p:i+1,date:dd.toISOString().slice(0,10),pay:Math.round(pay*100)/100,pr:Math.round(pr*100)/100,it:Math.round(mi*100)/100,rp:Math.round(Math.max(0,rp-pr)*100)/100});if(isLast)rp=0;}}return list;}
function renderAll(){renderList();renderRight();}
function renderList(){const el=$('loanList');if(!loans.length){el.innerHTML='<div class="empty-tip">暂无记录</div>';return;}let visibleLoans=loans;if(hideOthers)visibleLoans=loans.filter(l=>l.user_id===currentUser.id);const groups=new Map();visibleLoans.forEach(l=>{if(!groups.has(l.name))groups.set(l.name,[]);groups.get(l.name).push(l);});const sortedGroups=[...groups.entries()].sort(([,a],[,b])=>{const ea=a.reduce((m,l)=>l.date<m?l.date:m,'9999-99-99');const eb=b.reduce((m,l)=>l.date<m?l.date:m,'9999-99-99');return eb.localeCompare(ea);});let html='';sortedGroups.forEach(([name,items])=>{items.sort((a,b)=>b.date.localeCompare(a.date));let visibleItems=items;if(hidePaidItems)visibleItems=items.filter(l=>l.paid.length<l.periods);if(hidePaidItems&&!visibleItems.length)return;const isOpen=openGroups.has(name);const totalAmt=visibleItems.reduce((s,l)=>s+l.amount,0);const doneCount=items.filter(l=>l.paid.length>=l.periods).length;html+=`<div class="loan-group"><div class="loan-group-header" onclick="toggleGroup('${name}')"><span><span class="arrow${isOpen?' open':''}">▶</span><span class="gname">${name}</span></span><span class="gsummary">${visibleItems.length}笔 · ¥${fmt(totalAmt)}${doneCount===items.length?' · <span style="color:var(--green)">全部还清</span>':''}</span></div><div class="loan-group-body${isOpen?' open':''}">`;visibleItems.forEach(l=>{const mt=methodLabel(l.method),tc=methodTag(l.method);const allDone=l.paid.length>=l.periods;const noInt=l.rate===0;const rc=l.rate===0?'':l.rate<12?'rate-low':l.rate<=24?'rate-mid':'rate-high';const hasPopup=actionLoanId===l.id;const isOwner=l.user_id===currentUser.id;html+=`<div class="loan-item${l.id===editId?' active':''}${allDone?' done-item':''}" onclick="toggleLoanAction(${l.id})"><div style="flex:1"><div class="iname">${escapeHtml(l.purpose||'')}${allDone?'<span class="badge-done">已还清</span>':''} <span class="tag ${tc}">${mt}</span>${noInt?' <span class="tag tag-0">无息</span>':''}${l.owner_name?` <span class="tag" style="background:rgba(0,0,0,.04);color:var(--text2)">${l.owner_name}</span>`:''}</div><div class="isub">${l.paid.length}/${l.periods}期 · <span class="${rc}">${l.rate}%</span> · ${l.date}</div></div><div style="text-align:right"><div class="iamt">¥${fmt(l.amount)}</div></div></div>${hasPopup?`<div class="loan-actions-popup"><button class="act-btn act-btn-edit" onclick="event.stopPropagation();doEditLoan(${l.id})">✏️ 修改</button><button class="act-btn act-btn-pay" onclick="event.stopPropagation();toggleQuickPay(${l.id})">💰 快速还款</button>${isOwner?`<button class="act-btn act-btn-del" onclick="event.stopPropagation();showDel(${l.id})">🗑 删除</button>`:''}</div>`:''}${quickPayLoanId===l.id?renderQuickPay(l):''}`;});html+=`</div></div>`;});el.innerHTML=html;}
function toggleGroup(name){if(openGroups.has(name))openGroups.delete(name);else openGroups.add(name);renderList();}
function showLoanDetail(id){const l=loans.find(x=>x.id===id);if(!l)return;editId=id;$('fDate').value=l.date;$('fName').value=l.name;$('fPurpose').value=l.purpose||'';$('fAmt').value=l.amount;$('fRate').value=l.rate;$('fTotalInt').value='';$('fN').value=l.periods;$('fMethod').value=l.method;$('rateHint').textContent='';$('intHint').textContent='';$('formTitle').textContent='编辑中';$('btnSubmit').textContent='修改';document.querySelector('.main').scrollIntoView({behavior:'smooth',block:'start'});}
async function submitLoan(){clearTimeout(rateDebounce);clearTimeout(intDebounce);const a=+$('fAmt').value,ti=+$('fTotalInt').value,n=+$('fN').value,m=$('fMethod').value;let r=+$('fRate').value;if(a&&n&&!r&&ti){r=calcRateFromTotalInt(a,ti,n,m);$('fRate').value=r.toFixed(4);}const d=$('fDate').value,nm=$('fName').value.trim(),p=+$('fN').value;r=+$('fRate').value;if(!d||!nm||!a||isNaN(r)||r===''||!p)return showToast('请完善借款信息,年化率和总利息至少填写一项','warning');const schedule=calcSchedule(a,r,p,m,d);const loan={name:nm,date:d,amount:a,rate:Math.round(r*10000)/10000,periods:p,method:m,purpose:($('fPurpose').value||'').trim(),schedule,paid:[]};if(editId){const existing=loans.find(x=>x.id===editId);if(existing)loan.paid=existing.paid.filter(x=>x<=p);loan.id=editId;}const saved=await saveLoanToServer(loan);if(saved){if(editId){const idx=loans.findIndex(x=>x.id===editId);if(idx>=0)loans[idx]=saved;}else loans.push(saved);renderAll();clearForm();}}
let toggleLock=false;
async function togglePaid(id,p){if(toggleLock)return;const l=loans.find(x=>x.id===id);if(!l)return;if(l.user_id!==currentUser.id){showToast('仅支持本人操作还款','warning');return;}toggleLock=true;const wasPaid=l.paid.includes(p);if(wasPaid){l.paid=l.paid.filter(x=>x!==p);}else{l.paid=[...l.paid,p];}renderMonthPlan();renderStats();renderList();try{const serverPaid=await togglePaidOnServer(id,l.paid);if(serverPaid!==null){l.paid=serverPaid;}}catch(e){}toggleLock=false;renderMonthPlan();renderStats();renderList();}
async function payAllMonth(ym){if(toggleLock)return;toggleLock=true;const map=buildMonthMap();const items=map[ym];if(!items){toggleLock=false;return;}const unpaidItems=items.filter(r=>{const l=loans.find(x=>x.id===r.loanId);return l&&l.user_id===currentUser.id&&!l.paid.includes(r.p);});if(!unpaidItems.length){toggleLock=false;return;}for(const r of unpaidItems){const l=loans.find(x=>x.id===r.loanId);if(l&&!l.paid.includes(r.p)){l.paid=[...l.paid,r.p];}}renderMonthPlan();renderStats();renderList();for(const r of unpaidItems){const l=loans.find(x=>x.id===r.loanId);if(l){await togglePaidOnServer(r.loanId,l.paid);}}toggleLock=false;renderMonthPlan();renderStats();renderList();}
function clearForm(){$('fName').value='';$('fPurpose').value='';$('fAmt').value='';$('fRate').value='';$('fTotalInt').value='';$('fN').value='';$('fDate').value=today();$('rateHint').textContent='';$('intHint').textContent='';editId=null;$('formTitle').textContent='新增';$('btnSubmit').textContent='添加';}
function toggleLoanAction(id){if(actionLoanId===id){actionLoanId=null;quickPayLoanId=null;}else{actionLoanId=id;quickPayLoanId=null;}renderList();}
function doEditLoan(id){
const l=loans.find(x=>x.id===id);
if(!l)return;
if(l.user_id!==currentUser.id){showToast('仅支持修改本人的借款记录','warning');return;}
actionLoanId=null;quickPayLoanId=null;showLoanDetail(id);
}
function toggleQuickPay(id){quickPayLoanId=quickPayLoanId===id?null:id;renderList();}
function renderQuickPay(l){
const isOwner=l.user_id===currentUser.id;
let html=`<div class="quick-panel"><div class="qp-title">还款节点 · ${escapeHtml(l.name)} ${l.purpose?'· '+l.purpose:''}</div>`;
l.schedule.forEach(r=>{
const isPaid=l.paid.includes(r.p);
const btnClass=isPaid?'qp-btn-done':'qp-btn-pay';
const btnText=isPaid?'✓ 已还':'还款';
const disabled=!isOwner?(isPaid?'':`disabled title="只允许借款人还款"`):'';
const onclick=isOwner?`onclick="event.stopPropagation();togglePaid(${l.id},${r.p})"`:(isPaid?`onclick="event.stopPropagation();togglePaid(${l.id},${r.p})"`:'');
html+=`<div class="qp-row"><span class="qp-info">第${r.p}期 · ${r.date}</span><span class="qp-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</span><button class="qp-btn ${btnClass}" ${onclick}>${btnText}</button></div>`;
});
html+=`</div>`;
return html;
}
function showDel(id){const l=loans.find(x=>x.id===id);if(l&&l.user_id!==currentUser.id){showToast('仅支持删除本人的借款记录','warning');return;}delTarget=id;$('delModal').classList.add('show');}
function closeDel(){$('delModal').classList.remove('show');delTarget=null;}
async function confirmDel(){if(!delTarget)return;const ok=await deleteLoanFromServer(delTarget);if(ok){loans=loans.filter(l=>l.id!==delTarget);renderAll();}closeDel();}
function renderRight(){if(!loans.length){$('rightPanel').style.display='none';return;}$('rightPanel').style.display='';renderStats();renderMonthPlan();}
const statsExpanded=new Set();
function renderStats(){let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;loans.forEach(l=>{totalAmt+=l.amount;totalN+=l.periods;const s=l.schedule;totalInt+=s.reduce((a,r)=>a+r.it,0);paidN+=l.paid.length;totalPaid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const totalPay=totalAmt+totalInt;const memberMap=new Map();loans.forEach(l=>{const owner=l.owner_name||'未知';if(!memberMap.has(owner))memberMap.set(owner,{amt:0,int:0,paid:0,paidN:0,totalN:0});const m=memberMap.get(owner);m.amt+=l.amount;m.totalN+=l.periods;const s=l.schedule;m.int+=s.reduce((a,r)=>a+r.it,0);m.paidN+=l.paid.length;m.paid+=s.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});const cards=[{lbl:'借款总额',val:`¥${fmt(totalAmt)}`,cls:'blue',key:'amt'},{lbl:'总利息',val:`¥${fmt(totalInt)}`,cls:'red',key:'int'},{lbl:'总还款',val:`¥${fmt(totalPay)}`,cls:'',key:'pay'},{lbl:'已还',val:`¥${fmt(totalPaid)}`,cls:'green',key:'paid'},{lbl:'待还',val:`¥${fmt(totalPay-totalPaid)}`,cls:'red',key:'remain'},{lbl:'已还期数',val:`${paidN}/${totalN}`,cls:'green',key:'periods'}];let html='';cards.forEach(c=>{const isOpen=statsExpanded.has(c.key);html+=`<div class="stat-block"><div class="stat-card clickable" onclick="toggleStat('${c.key}')"><div class="lbl">${c.lbl}</div><div class="val ${c.cls}">${c.val}</div><div class="stat-arrow${isOpen?' open':''}">▶</div></div>`;if(isOpen){html+=`<div class="stats-detail-wrap">`;memberMap.forEach((m,owner)=>{const vals={amt:fmt(m.amt),int:fmt(m.int),pay:fmt(m.amt+m.int),paid:fmt(m.paid),remain:fmt(m.amt+m.int-m.paid),periods:`${m.paidN}/${m.totalN}`};html+=`<div class="stat-sub"><span>${owner}</span><span class="stat-sub-val">${vals[c.key]}</span></div>`;});html+=`</div>`;}html+=`</div>`;});$('statsGrid').innerHTML=html;}
function toggleStat(key){if(statsExpanded.has(key))statsExpanded.delete(key);else statsExpanded.add(key);renderStats();}
function buildMonthMap(){const map={};const visibleLoans=hideOthers?loans.filter(l=>l.user_id===currentUser.id):loans;visibleLoans.forEach(l=>{const displayName=l.purpose?`${escapeHtml(l.name)} · ${l.purpose}`:l.name;const ownerTag=l.owner_name?` [${l.owner_name}]`:'';l.schedule.forEach(r=>{const ym=ymOf(r.date);if(!map[ym])map[ym]=[];map[ym].push({loanId:l.id,loanName:displayName+ownerTag,method:l.method,...r,paid:l.paid.includes(r.p)});});});return map;}
function renderMonthPlan(){const map=buildMonthMap();let months=Object.keys(map).sort();if(!months.length){$('monthList').innerHTML='<div class="empty-tip">暂无还款计划</div>';return;}if(hidePaidMonths)months=months.filter(ym=>{const items=map[ym];return items.some(r=>!r.paid);});if(!months.length){$('monthList').innerHTML='<div class="empty-tip">所有月份已还清</div>';return;}const now=today(),nowYm=now.slice(0,7);$('monthList').innerHTML=months.map(ym=>{const items=map[ym];const monthTotal=items.reduce((a,r)=>a+r.pay,0);const paidCount=items.filter(r=>r.paid).length;const allPaid=paidCount===items.length;const isPast=ym<nowYm;const isNow=ym===nowYm;const isOpen=openMonths.has(ym);const payAllBtn=allPaid?'':`<button class="btn-pay-all" onclick="event.stopPropagation();payAllMonth('${ym}')">一键还款</button>`;return`<div class="month-group"><div class="month-header" onclick="toggleMonth('${ym}')"><span class="ym">${ym.replace('-','年')}${isNow?' (本月)':''}${isPast&&!allPaid?' ⚠️':''}</span><span style="display:flex;align-items:center;gap:12px"><span style="font-size:11px;color:var(--text3)">${paidCount}/${items.length}笔</span>${payAllBtn}<span class="total"${allPaid?' style="color:var(--green)"':''}>${allPaid?'✓ ':''}¥${fmt(monthTotal)}</span><span class="arrow${isOpen?' open':''}">▶</span></span></div><div class="month-detail${isOpen?' show':''}">${items.map(r=>{const ml=methodLabel(r.method);const ds=r.method==='i'?`利息 ¥${fmt(r.it)}${r.pr>0?' + 本金 ¥'+fmt(r.pr):''}`:`本金 ¥${fmt(r.pr)} + 利息 ¥${fmt(r.it)}`;const isChecked=r.paid;const cls='pay-check'+(isChecked?' checked':'')+(toggleLock?' disabled':'');return`<div class="loan-row${isChecked?' paid':''}" data-loan="${r.loanId}" data-period="${r.p}"><div class="left"><span class="borrower">${r.loanName}${isChecked?' <span class="paid-badge">✓ 已还</span>':''}</span><span class="detail">${ml} · 第${r.p}期 · ${ds}</span></div><div class="right"><div class="pay-amt">¥${r.pay.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g,',')}</div><div class="pay-date">${r.date}</div></div><div class="${cls}" onclick="event.stopPropagation();togglePaid(${r.loanId},${r.p})"></div></div>`;}).join('')}</div></div>`;}).join('');}
function toggleHidePaid(){hidePaidItems=!hidePaidItems;$('hidePaidIcon').textContent=hidePaidItems?'🙈':'👁';$('hidePaidIcon').nextSibling.textContent=hidePaidItems?' 显示已还清':' 隐藏已还清';renderList();}
function toggleHidePaidMonths(){hidePaidMonths=!hidePaidMonths;$('hidePaidMonthsIcon').textContent=hidePaidMonths?'🙈':'👁';$('hidePaidMonthsIcon').nextSibling.textContent=hidePaidMonths?' 显示已还完':' 隐藏已还完';renderMonthPlan();}
function toggleHideOthers(){hideOthers=!hideOthers;$('hideOthersIcon').textContent=hideOthers?'🙈':'👁';$('hideOthersIcon').nextSibling.textContent=hideOthers?' 显示他人':' 隐藏他人';$('hideOthersIcon2').textContent=hideOthers?'🙈':'👁';$('hideOthersIcon2').nextSibling.textContent=hideOthers?' 显示他人':' 隐藏他人';renderList();renderMonthPlan();}
function toggleMonth(ym){if(openMonths.has(ym))openMonths.delete(ym);else openMonths.add(ym);renderMonthPlan();}
function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('exportMenu').classList.remove('show');$('importMenu').classList.remove('show');$('userMenu').classList.remove('show');});
function download(filename,content,mime){const blob=new Blob(['\uFEFF'+content],{type:mime});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download=filename;a.click();}
function getVisibleLoans(){return currentUser&&currentUser.role==='admin'?loans:loans.filter(l=>l.user_id===currentUser.id);}
function exportCSV(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.length)return showToast('暂无数据可导出','info');let csv='债权人,借款日期,借款金额,年化率(%),总利息,分期数,还款方式,已还期数,状态,用途\n';vl.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const paid=l.paid.length+'/'+l.periods;const status=l.paid.length>=l.periods?'已还清':'还款中';csv+=`${escapeHtml(l.name)},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status},${escapeHtml(l.purpose||'')}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
function exportJSON(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.length)return showToast('暂无数据可导出','info');const data=vl.map(l=>({债权人:l.name,借款日期:l.date,借款金额:l.amount,年化率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',用途:l.purpose||'',还款计划:l.schedule.map(r=>({期数:r.p,日期:r.date,还款额:r.pay,本金:r.pr,利息:r.it}))}));download('债务统计_'+today()+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
function exportHTML(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.length)return showToast('暂无数据可导出','info');let h=`<html><head><meta charset="utf-8"><title>债务统计</title><style>body{font-family:"Microsoft YaHei",sans-serif;padding:20px}h2{color:#1d1d1f}table{border-collapse:collapse;width:100%;font-size:13px;margin-bottom:20px}th{background:#0071e3;color:#fff;padding:8px 12px;text-align:left}td{padding:6px 12px;border-bottom:1px solid #d2d2d7}tr:nth-child(even){background:#f5f5f7}.done{color:#34c759;font-weight:600}
.profile-section{padding:4px 0}
.profile-section .form-row{margin-bottom:14px}
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
</style></head><body><h2>债务统计报表 - ${today()}</h2>`;vl.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${escapeHtml(l.name)} - ${l.date} - ¥${fmt(l.amount)}${st?' <span class="done">已还清</span>':''}</h3><p>年化率 ${l.rate}% · ${methodLabel(l.method)} · ${l.periods}期 · 总利息 ¥${ti.toFixed(2)}</p><table><tr><th>期数</th><th>日期</th><th>还款额</th><th>本金</th><th>利息</th><th>剩余本金</th><th>状态</th><th>用途</th></tr>`;l.schedule.forEach(r=>{h+=`<tr><td>${r.p}</td><td>${r.date}</td><td>¥${fmt(r.pay)}</td><td>¥${fmt(r.pr)}</td><td>¥${fmt(r.it)}</td><td>¥${fmt(r.rp)}</td><td>${l.paid.includes(r.p)?'✓ 已还':'待还'}</td></tr>`;});h+=`</table>`;});h+=`
<div class="modal-mask" id="profileModal">
<div class="modal" style="max-width:480px">
<h3>个人中心</h3>
<div class="profile-section">
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
<div class="form-row">
<label>邮箱</label>
<div style="display:flex;gap:8px">
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
</div>
</div>
<div class="form-row" id="emailCodeRow" style="display:none">
<label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
<button class="btn btn-main" onclick="saveProfile()">保存</button>
</div>
</div>
</div>
</div>
</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('importMenu').classList.remove('show');});
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');$('importFile').value='';$('importError').style.display='none';$('importSuccess').style.display='none';$('importModal').classList.add('show');}
function closeImportModal(){$('importModal').classList.remove('show');}
function downloadTemplate(e){e.preventDefault();$('importMenu').classList.remove('show');let csv='债权人,借款日期,借款金额,年化率(%),总利息,分期数,还款方式,已还期数,状态,用途\n';csv+='张三,2026-01-01,100000,5.7,5198.99,24,等额本息,0/24,还款中,装修\n';csv+='李四,2026-03-15,50000,8,1200.00,12,先息后本,0/12,还款中,教育\n';csv+='王五,2026-06-01,30000,0,0.00,6,等额本息,0/6,还款中,\n';download('导入模板.csv',csv,'text/csv;charset=utf-8');}
async function doImport(){const file=$('importFile').files[0];if(!file){$('importError').textContent='请选择文件';$('importError').style.display='';return;}$('importError').style.display='none';$('importSuccess').style.display='none';const text=await file.text();const lines=text.trim().split('\n');if(lines.length<2){$('importError').textContent='文件为空或格式错误';$('importError').style.display='';return;}const header=lines[0].split(',').map(s=>s.trim());const nameIdx=header.indexOf('债权人'),dateIdx=header.indexOf('借款日期'),amtIdx=header.indexOf('借款金额'),rateIdx=header.indexOf('年利率(%)'),periodsIdx=header.indexOf('分期数'),methodIdx=header.indexOf('还款方式'),paidIdx=header.indexOf('已还期数');if(nameIdx===-1||dateIdx===-1||amtIdx===-1||rateIdx===-1||periodsIdx===-1||methodIdx===-1){$('importError').textContent='缺少必需列:债权人,借款日期,借款金额,年化率(%),分期数,还款方式';$('importError').style.display='';return;}const methodMap={'等额本息':'e','等额本金':'p','先息后本':'i','e':'e','p':'p','i':'i'};let imported=0,failed=0;for(let i=1;i<lines.length;i++){const line=lines[i].trim();if(!line)continue;const cols=line.split(',').map(s=>s.trim());if(cols.length<6)continue;const name=cols[nameIdx],date=cols[dateIdx],amount=parseFloat(cols[amtIdx]),rate=parseFloat(cols[rateIdx]),periods=parseInt(cols[periodsIdx]),method=methodMap[cols[methodIdx]];if(!name||!date||isNaN(amount)||isNaN(rate)||isNaN(periods)||!method){failed++;continue;}const schedule=calcSchedule(amount,rate,periods,method,date);let paid=[];if(paidIdx>=0&&cols[paidIdx]){const paidStr=cols[paidIdx].trim();const m=paidStr.match(/^(\d+)\//);if(m){const paidCount=parseInt(m[1]);for(let p=1;p<=Math.min(paidCount,periods);p++)paid.push(p);}}const purpose=purposeIdx>=0?cols[purposeIdx]||'':'';const loan={name,date,amount,rate,periods,method,purpose,schedule,paid};const saved=await saveLoanToServer(loan);if(saved)loans.push(saved);else failed++;imported++;}if(imported>0){$('importSuccess').style.display='';$('importSuccess').textContent=`成功导入 ${imported}${failed?`,失败 ${failed}`:''}`;renderAll();}else{$('importError').textContent='导入失败,请检查文件格式';$('importError').style.display='';}}
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
document.addEventListener('click',function(){$('userMenu').classList.remove('show');});
function showChangeNick(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('newNick').value='';$('nickError').style.display='none';$('nickSuccess').style.display='none';$('changeNickModal').classList.add('show');}
function closeChangeNick(){$('changeNickModal').classList.remove('show');}
async function doChangeNick(){const nick=$('newNick').value.trim();if(!nick)return $('nickError').textContent='请输入昵称';$('nickError').style.display='';$('nickError').style.display='none';const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});if(res&&res.ok){$('nickSuccess').style.display='';$('nickSuccess').textContent='昵称修改成功';currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}else{const d=await res.json();$('nickError').textContent=d.detail||'修改失败';$('nickError').style.display='';}}
function showChangePwd(e){if(e)e.preventDefault();$('userMenu').classList.remove('show');$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';$('pwdError').style.display='none';$('pwdSuccess').style.display='none';$('changePwdModal').classList.add('show');}
function closeChangePwd(){$('changePwdModal').classList.remove('show');}
async function doChangePwd(){const old=$('oldPwd').value,nw=$('newPwd').value,nw2=$('newPwd2').value;if(!old||!nw)return $('pwdError').textContent='请填写所有字段';$('pwdError').style.display='';if(nw!==nw2){$('pwdError').textContent='两次新密码不一致';$('pwdError').style.display='';return;}$('pwdError').style.display='none';const res=await apiFetch('/auth/password',{method:'PUT',body:JSON.stringify({old_password:old,new_password:nw})});if(res&&res.ok){$('pwdSuccess').style.display='';$('pwdSuccess').textContent='密码修改成功';$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';}else{const d=await res.json();$('pwdError').textContent=d.detail||'修改失败';$('pwdError').style.display='';}}
async function doBindEmail(){
const email=$('bindEmail').value.trim();
if(!email||!email.includes('@'))return $('bindEmailError').textContent='请输入有效邮箱地址';$('bindEmailError').style.display='';
$('bindEmailError').style.display='none';
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({real_email:email})});
if(res&&res.ok){currentUser.real_email=email;$('emailModal').classList.remove('show');showToast('邮箱绑定成功','success');}
else{const d=await res.json();$('bindEmailError').textContent=d.detail||'绑定失败';$('bindEmailError').style.display='';}
}
let fpAccount='';
function showForgotStep1(){$('fpAccount').value='';$('fpStep1Error').style.display='none';$('forgotStep1').classList.add('show');}
function closeForgotModals(){$('forgotStep1').classList.remove('show');$('forgotStep2').classList.remove('show');$('forgotStep3').classList.remove('show');}
async function doForgotStep1(){
const account=$('fpAccount').value.trim();
if(!account){$('fpStep1Error').textContent='请输入账号或邮箱';$('fpStep1Error').style.display='';return;}
$('fpStep1Error').style.display='none';
const res=await fetch(API+'/auth/forgot-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account})});
const data=await res.json();
fpAccount=account;
$('forgotStep1').classList.remove('show');
$('fpCode').value='';$('fpStep2Error').style.display='none';
$('forgotStep2').classList.add('show');
showToast(data.message||'验证码已发送','success');
}
async function doForgotStep2(){
const code=$('fpCode').value.trim();
if(!code||code.length!==6){$('fpStep2Error').textContent='请输入 6 位验证码';$('fpStep2Error').style.display='';return;}
$('fpStep2Error').style.display='none';
const res=await fetch(API+'/auth/verify-reset-code',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:fpAccount,code})});
if(res.ok){
$('forgotStep2').classList.remove('show');
$('fpNewPwd').value='';$('fpNewPwd2').value='';$('fpStep3Error').style.display='none';
$('forgotStep3').classList.add('show');
}else{
const d=await res.json();
$('fpStep2Error').textContent=d.detail||'验证码错误';$('fpStep2Error').style.display='';
}
}
async function doForgotStep3(){
const nw=$('fpNewPwd').value,nw2=$('fpNewPwd2').value;
if(!nw){$('fpStep3Error').textContent='请输入新密码';$('fpStep3Error').style.display='';return;}
if(nw!==nw2){$('fpStep3Error').textContent='两次密码不一致';$('fpStep3Error').style.display='';return;}
$('fpStep3Error').style.display='none';
const code=$('fpCode').value.trim();
const res=await fetch(API+'/auth/reset-password',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:fpAccount,code,new_password:nw})});
if(res.ok){
closeForgotModals();
showToast('密码重置成功,请重新登录','success');
}else{
const d=await res.json();
$('fpStep3Error').textContent=d.detail||'重置失败';$('fpStep3Error').style.display='';
}
}
checkAuth().then(ok=>{if(ok)loadLoans();});
</script>
<div class="modal-mask" id="profileModal">
<div class="modal" style="max-width:480px">
<h3>个人中心</h3>
<div class="profile-section">
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
<div class="form-row">
<label>邮箱</label>
<div style="display:flex;gap:8px">
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
</div>
</div>
<div class="form-row" id="emailCodeRow" style="display:none">
<label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
<button class="btn btn-main" onclick="saveProfile()">保存</button>
</div>
</div>
</div>
</div>
</body>
</html>