功能: - 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知 - 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章 - 定时任务: APScheduler 每小时检查即将到期的还款 - 邮件通知: SMTP 发送还款提醒和安全警告 - 异常登录通知: 3/5/10次失败触发网页和邮件通知 安全: - 登录安全告警: 异常登录次数达到阈值时发送通知 - 密码重置链接: 一次性使用, 5分钟有效期 - 登录频率限制: IP级和账户级限制 技术: - 后端: FastAPI + SQLAlchemy + PostgreSQL - 前端: 纯 HTML/CSS/JS - 部署: Docker Compose (新增 build 配置)
669 lines
66 KiB
HTML
669 lines
66 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>
|
||
<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;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 h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px}
|
||
.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)}
|
||
|
||
/* 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}
|
||
</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">
|
||
<h1>债务统计</h1>
|
||
<div class="header-right">
|
||
<div class="export-wrap" style="position:relative">
|
||
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
|
||
<svg viewBox="0 0 24 24"><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="showChangeNick(event)">修改昵称</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);
|
||
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">${n.title}</div><div class="notif-msg">${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;
|
||
}
|
||
|
||
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">${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">还款节点 · ${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?`${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');});
|
||
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 exportCSV(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';loans.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+=`${l.name},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
|
||
function exportJSON(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');const data=loans.map(l=>({借款人:l.name,借款日期:l.date,借款金额:l.amount,年利率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',还款计划: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();if(!loans.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}</style></head><body><h2>债务统计报表 - ${today()}</h2>`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${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></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+=`</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 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>
|
||
</body>
|
||
</html>
|