4 Commits
v2.4 ... main

Author SHA1 Message Date
OP
d490b18b7e fix: 完全重写 analysis.html 修复所有损坏
- 清除了所有损坏的 HTML/JS 代码
- 重写为干净的结构
- 包含完整的 header/menu/modal
- 包含所有图表功能
- 与 index.html 功能一致
2026-07-02 21:42:31 +08:00
OP
15a06d33be fix: 修复 analysis.html twofaBindModal 位置错误
- twofaBindModal 之前被错误放在 <head> 内
- 移动到 <body> 内的正确位置
- 删除残留的 modal 内容
- 现在弹窗应该正确居中显示
2026-07-02 21:04:49 +08:00
OP
908bab811e fix: 同步两个页面的弹窗结构
- index.html 添加 profileModal HTML
- analysis.html 移除错误位置的 twofaBindModal
- analysis.html 添加 twofaBindModal 到正确位置
- analysis.html profileModal 添加完整 2FA 设置部分
- 两个页面的 header/menu/modal 现在一致

不打包,等用户确认后再打包
2026-07-02 20:53:29 +08:00
OP
a0c57e0f21 v2.4.1: 修复 2FA 登录和前端数据加载问题
- 修复登录接口 response_model 导致 2FA 返回格式验证失败(500错误)
- 修复 showProfile 未调用 loadTwofaStatus 导致 2FA 状态一直显示加载中
- 修复 hideOthers 默认值为 false 确保管理员可查看所有数据
- 删除重复的 doLogin 函数
- 删除残留的代码片段修复 JavaScript 语法错误
2026-07-02 18:04:39 +08:00
8 changed files with 697 additions and 291 deletions

View File

@@ -35,7 +35,7 @@ async def register(req: RegisterRequest, db: AsyncSession = Depends(get_db)):
return RegisterResponse(message="注册请求已提交,等待管理员审批") return RegisterResponse(message="注册请求已提交,等待管理员审批")
@router.post("/login", response_model=TokenResponse) @router.post("/login")
async def login( async def login(
req: LoginRequest, req: LoginRequest,
request: Request, request: Request,
@@ -81,6 +81,17 @@ async def login(
if user.status == "disabled": if user.status == "disabled":
raise HTTPException(status_code=403, detail="账户已被禁用") raise HTTPException(status_code=403, detail="账户已被禁用")
clear_login_failures(ip, account) clear_login_failures(ip, account)
# 检查是否启用 2FA
if user.two_factor_enabled and user.two_factor_secret:
# 生成临时 token等待 2FA 验证
from app.core.security import create_token
temp_token = create_token(
{"sub": str(user.id), "type": "2fa_pending"},
timedelta(minutes=5)
)
return {"require_2fa": True, "temp_token": temp_token}
return TokenResponse( return TokenResponse(
access_token=create_access_token(user.id), access_token=create_access_token(user.id),
refresh_token=create_refresh_token(user.id), refresh_token=create_refresh_token(user.id),

153
backend/app/api/twofa.py Normal file
View File

@@ -0,0 +1,153 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
import pyotp
import qrcode
import io
import base64
from app.core.database import get_db
from app.core.deps import get_current_user
from app.models.models import User
router = APIRouter(prefix="/auth/2fa", tags=["双因素认证"])
class TwoFASetupResponse(BaseModel):
secret: str
qr_code: str # base64 encoded QR code image
otpauth_url: str
class TwoFAVerifyRequest(BaseModel):
code: str
class TwoFALoginRequest(BaseModel):
temp_token: str
code: str
@router.post("/setup", response_model=TwoFASetupResponse)
async def setup_2fa(
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""生成 2FA 密钥和二维码"""
secret = pyotp.random_base32()
totp = pyotp.TOTP(secret)
otpauth_url = totp.provisioning_uri(
name=user.email,
issuer_name="债务管理系统"
)
# 生成 QR 码
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(otpauth_url)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
# 转换为 base64
buffer = io.BytesIO()
img.save(buffer, format="PNG")
qr_base64 = base64.b64encode(buffer.getvalue()).decode()
# 临时存储 secret验证后才会真正保存
user.two_factor_secret = secret
await db.commit()
return TwoFASetupResponse(
secret=secret,
qr_code=f"data:image/png;base64,{qr_base64}",
otpauth_url=otpauth_url
)
@router.post("/verify")
async def verify_2fa_setup(
req: TwoFAVerifyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""验证 2FA 设置(首次验证后启用)"""
if not user.two_factor_secret:
raise HTTPException(status_code=400, detail="请先执行 2FA 设置")
totp = pyotp.TOTP(user.two_factor_secret)
if not totp.verify(req.code, valid_window=1):
raise HTTPException(status_code=400, detail="验证码错误")
# 启用 2FA
user.two_factor_enabled = 1
await db.commit()
return {"message": "双因素认证已启用"}
@router.post("/disable")
async def disable_2fa(
req: TwoFAVerifyRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""禁用 2FA需要验证当前验证码"""
if not user.two_factor_enabled:
raise HTTPException(status_code=400, detail="未启用双因素认证")
if not user.two_factor_secret:
raise HTTPException(status_code=400, detail="2FA 配置异常")
totp = pyotp.TOTP(user.two_factor_secret)
if not totp.verify(req.code, valid_window=1):
raise HTTPException(status_code=400, detail="验证码错误")
user.two_factor_secret = None
user.two_factor_enabled = 0
await db.commit()
return {"message": "双因素认证已禁用"}
@router.post("/verify-login")
async def verify_2fa_login(
req: TwoFALoginRequest,
db: AsyncSession = Depends(get_db),
):
"""验证 2FA 登录密码验证后2FA 验证前)"""
from app.core.security import decode_token, create_access_token, create_refresh_token
# 验证临时 token
payload = decode_token(req.temp_token)
if not payload or payload.get("type") != "2fa_pending":
raise HTTPException(status_code=401, detail="无效的验证请求")
user_id = int(payload["sub"])
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user or not user.two_factor_secret:
raise HTTPException(status_code=401, detail="用户状态异常")
# 验证 2FA
totp = pyotp.TOTP(user.two_factor_secret)
if not totp.verify(req.code, valid_window=1):
raise HTTPException(status_code=401, detail="验证码错误")
# 返回正式 token
return {
"access_token": create_access_token(user.id),
"refresh_token": create_refresh_token(user.id),
"token_type": "bearer"
}
@router.get("/status")
async def get_2fa_status(
user: User = Depends(get_current_user),
):
"""获取 2FA 状态"""
return {
"enabled": bool(user.two_factor_enabled),
"has_secret": bool(user.two_factor_secret)
}

View File

@@ -6,7 +6,7 @@ from app.core.config import get_settings
from app.core.database import engine, async_session from app.core.database import engine, async_session
from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group from app.models.models import Base, User, Debt, RepaymentPlan, RepaymentRecord, Group
from app.core.security import hash_password from app.core.security import hash_password
from app.api import auth, debts, repayments, admin, loans, notifications from app.api import auth, debts, repayments, admin, loans, notifications, twofa
from sqlalchemy import select, text from sqlalchemy import select, text
settings = get_settings() settings = get_settings()
@@ -52,10 +52,23 @@ async def migrate_account_names():
async def migrate_add_real_email(): async def migrate_add_real_email():
async with engine.begin() as conn:
try:
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''"
))
except Exception:
pass # 表不存在时跳过,由 seed_admin 创建
async def migrate_add_2fa():
async with engine.begin() as conn: async with engine.begin() as conn:
await conn.execute(text( await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS real_email VARCHAR(255) DEFAULT ''" "ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_secret VARCHAR(32)"
)) ))
await conn.execute(text(
"ALTER TABLE users ADD COLUMN IF NOT EXISTS two_factor_enabled INTEGER DEFAULT 0"
))
async def migrate_debt_data(): async def migrate_debt_data():
@@ -98,6 +111,7 @@ async def migrate_debt_data():
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await seed_admin() await seed_admin()
await migrate_add_real_email() await migrate_add_real_email()
await migrate_add_2fa()
await migrate_account_names() await migrate_account_names()
await migrate_debt_data() await migrate_debt_data()
from app.services.scheduler import start_scheduler, check_upcoming_repayments from app.services.scheduler import start_scheduler, check_upcoming_repayments
@@ -139,6 +153,7 @@ app.include_router(debts.router, prefix=settings.API_V1_PREFIX)
app.include_router(repayments.router, prefix=settings.API_V1_PREFIX) app.include_router(repayments.router, prefix=settings.API_V1_PREFIX)
app.include_router(admin.router, prefix=settings.API_V1_PREFIX) app.include_router(admin.router, prefix=settings.API_V1_PREFIX)
app.include_router(notifications.router, prefix=settings.API_V1_PREFIX) app.include_router(notifications.router, prefix=settings.API_V1_PREFIX)
app.include_router(twofa.router, prefix=settings.API_V1_PREFIX)
@app.get("/health") @app.get("/health")

View File

@@ -73,6 +73,8 @@ class User(Base):
status = Column(String(20), default=UserStatus.ACTIVE.value) status = Column(String(20), default=UserStatus.ACTIVE.value)
group_id = Column(Integer, ForeignKey("groups.id"), nullable=True) group_id = Column(Integer, ForeignKey("groups.id"), nullable=True)
created_at = Column(DateTime, default=lambda: datetime.utcnow()) created_at = Column(DateTime, default=lambda: datetime.utcnow())
two_factor_secret = Column(String(32), nullable=True)
two_factor_enabled = Column(Integer, default=0)
group = relationship("Group", back_populates="members") group = relationship("Group", back_populates="members")
debts = relationship("Debt", back_populates="user") debts = relationship("Debt", back_populates="user")

View File

@@ -14,3 +14,6 @@ httpx==0.26.0
python-dateutil==2.8.2 python-dateutil==2.8.2
apscheduler==3.10.4 apscheduler==3.10.4
aiosmtplib==3.0.1 aiosmtplib==3.0.1
pyotp==2.9.0
qrcode==7.4.2
pillow==10.2.0

View File

@@ -147,7 +147,7 @@ tr:hover{background:#f8fafc}
</div> </div>
<div class="card-body"> <div class="card-body">
<table> <table>
<thead><tr><th>账号</th><th>昵称</th><th>邮箱</th><th>角色</th><th></th><th>债务</th><th>状态</th><th>操作</th></tr></thead> <thead><tr><th>账号</th><th>昵称</th><th>邮箱</th><th>角色</th><th></th><th>债务</th><th>状态</th><th>2FA</th><th>操作</th></tr></thead>
<tbody id="userTable"></tbody> <tbody id="userTable"></tbody>
</table> </table>
</div> </div>
@@ -348,7 +348,9 @@ async function loadUsers(){
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${escapeHtml(u.account)}')">改密</button>`; actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${escapeHtml(u.account)}')">改密</button>`;
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${escapeHtml(u.account)}')">删除</button>`; actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${escapeHtml(u.account)}')">删除</button>`;
} }
return`<tr><td>${escapeHtml(u.account)}</td><td>${escapeHtml(u.nickname)}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td><td>${actions}</td></tr>`; return`<tr><td>${escapeHtml(u.account)}</td><td>${escapeHtml(u.nickname)}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td>
<td>${u.role==='admin'?'<span style="color:#94a3b8">-</span>':(u.two_factor_enabled?'<span class="badge badge-active">已启用</span> <button class="btn-sm btn-disable" onclick="toggleUser2FA('+u.id+',false)">禁用</button>':'<span class="badge badge-disabled">未启用</span> <button class="btn-sm btn-enable" onclick="toggleUser2FA('+u.id+',true)">启用</button>')}</td>
<td>${actions}</td></tr>`;
}).join(''); }).join('');
} }
@@ -514,6 +516,25 @@ function renderNotifList(items){const el=$("notifList");if(!items.length){el.inn
async function readNotif(id){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();} async function readNotif(id){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();}
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();} async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
async function toggleUser2FA(userId, enabled) {
const action = enabled ? '启用' : '禁用';
if (!confirm(`确定${action}该用户的双因素认证?`)) return;
const res = await apiFetch('/admin/users/' + userId + '/2fa', {
method: 'PUT',
body: JSON.stringify({enabled})
});
if (res && res.ok) {
alert(`用户 2FA 已${action}`);
loadUsers();
} else {
const data = await res.json().catch(() => ({}));
alert(data.detail || '操作失败');
}
}
init(); init();
</script> </script>
</body> </body>

View File

@@ -9,7 +9,7 @@
<style> <style>
:root{--bg:#f5f5f7;--card:rgba(255,255,255,.8);--border:rgba(0,0,0,.06);--text:#1d1d1f;--text2:#6e6e73;--text3:#aeaeb2;--blue:#0071e3;--green:#34c759;--red:#ff3b30;--orange:#ff9500;--purple:#af52de;--radius:16px;--radius-sm:10px;--radius-xs:8px} :root{--bg:#f5f5f7;--card:rgba(255,255,255,.8);--border:rgba(0,0,0,.06);--text:#1d1d1f;--text2:#6e6e73;--text3:#aeaeb2;--blue:#0071e3;--green:#34c759;--red:#ff3b30;--orange:#ff9500;--purple:#af52de;--radius:16px;--radius-sm:10px;--radius-xs:8px}
*{margin:0;padding:0;box-sizing:border-box} *{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC",sans-serif;background:var(--bg);color:var(--text);min-height:100vh;font-size:14px} 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}
.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{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-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} .header h1{font-size:17px;font-weight:600;color:var(--text);letter-spacing:-.2px}
@@ -53,9 +53,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
.export-menu.show{display:block} .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{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)} .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)} .container{max-width:1200px;margin:0 auto;padding:24px}
.user-info:hover{background:rgba(0,0,0,.06)}
.container{max-width:1120px;margin:0 auto;padding:24px}
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px} .stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-bottom:24px}
.stat-card{background:var(--card);border-radius:var(--radius-sm);padding:20px;text-align:center;border:1px solid var(--border)} .stat-card{background:var(--card);border-radius:var(--radius-sm);padding:20px;text-align:center;border:1px solid var(--border)}
.stat-card .lbl{font-size:12px;color:var(--text3);margin-bottom:6px;font-weight:500} .stat-card .lbl{font-size:12px;color:var(--text3);margin-bottom:6px;font-weight:500}
@@ -67,9 +65,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
.chart-wrap{position:relative;height:280px} .chart-wrap{position:relative;height:280px}
.full-width{grid-column:1/-1} .full-width{grid-column:1/-1}
.empty-tip{text-align:center;padding:40px;color:var(--text3);font-size:13px} .empty-tip{text-align:center;padding:40px;color:var(--text3);font-size:13px}
@media(max-width:900px){.stats-row{grid-template-columns:repeat(2,1fr)}.charts-grid{grid-template-columns:1fr}} .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{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)}
.modal-mask.show{display:flex} .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)} .modal{background:#fff;border-radius:var(--radius);padding:28px;max-width:420px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.15)}
.modal h3{margin-bottom:18px;font-size:17px;color:var(--text);font-weight:600} .modal h3{margin-bottom:18px;font-size:17px;color:var(--text);font-weight:600}
@@ -79,96 +75,54 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
.modal .form-row input:focus{border-color:var(--blue)} .modal .form-row input:focus{border-color:var(--blue)}
.modal .btn-row{display:flex;gap:10px;margin-top:18px} .modal .btn-row{display:flex;gap:10px;margin-top:18px}
.btn-ghost{background:#fff;color:var(--text2);border:1px solid #d2d2d7;padding:10px 0;border-radius:var(--radius-xs);font-size:14px;cursor:pointer;flex:1;font-family:inherit} .btn-ghost{background:#fff;color:var(--text2);border:1px solid #d2d2d7;padding:10px 0;border-radius:var(--radius-xs);font-size:14px;cursor:pointer;flex:1;font-family:inherit}
.btn-ghost:hover{background:#f5f5f7}
.btn-main{background:var(--blue);color:#fff;padding:10px 0;border:none;border-radius:var(--radius-xs);font-size:14px;font-weight:500;cursor:pointer;flex:1;font-family:inherit} .btn-main{background:var(--blue);color:#fff;padding:10px 0;border:none;border-radius:var(--radius-xs);font-size:14px;font-weight:500;cursor:pointer;flex:1;font-family:inherit}
.error-msg{color:var(--red);font-size:12px;margin-top:8px;display:none} .error-msg{color:var(--red);font-size:12px;margin-top:8px;display:none}
.error-msg.show{display:block} .error-msg.show{display:block}
.success-msg{color:var(--green);font-size:13px;margin-top:8px;display:none} .success-msg{color:var(--green);font-size:13px;margin-top:8px;display:none}
.success-msg.show{display:block} .success-msg.show{display:block}
#loginPage,#registerPage{display:none}
.profile-section{padding:4px 0} .auth-page{display:flex;align-items:center;justify-content:center;min-height:100vh;background:var(--bg)}
.profile-section .form-row{margin-bottom:14px} .auth-card{background:rgba(255,255,255,.72);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)}
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)} .auth-card h2{text-align:center;margin-bottom:32px;color:var(--text);font-size:22px;font-weight:700}
.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} .auth-card .form-row{margin-bottom:18px}
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)} .auth-card .form-row label{font-size:13px;font-weight:500}
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)} .auth-card .form-row input{padding:12px 16px;font-size:15px;width:100%;border:1px solid #d2d2d7;border-radius:var(--radius-xs);outline:none}
/* RESPONSIVE - Mobile & Tablet */ .auth-card .links{text-align:center;margin-top:18px}
@media(max-width:768px){ .auth-card .links a{color:var(--blue);text-decoration:none;font-size:13px;font-weight:500}
.header-inner{padding:0 12px;height:48px} @media(max-width:900px){.stats-row{grid-template-columns:repeat(2,1fr)}.charts-grid{grid-template-columns:1fr}}
.header h1{font-size:15px} @media(max-width:480px){.stats-row{grid-template-columns:1fr 1fr}.stat-card .val{font-size:18px}}
.header-center{position:static;transform:none}
.nav-tabs{gap:2px;padding:2px}
.nav-tab{padding:5px 12px;font-size:12px}
.header-right{gap:2px}
.header-right a,.hdr-btn{padding:5px 8px;font-size:11px}
.user-info{padding:5px 8px;font-size:12px}
.notif-bell{padding:4px 6px}
.notif-dropdown{width:calc(100vw - 20px);right:-10px}
.main{padding:12px;gap:12px}
.panel-title{padding:12px 14px;font-size:13px}
.panel-body{padding:12px 14px}
.form-grid{grid-template-columns:1fr;gap:10px}
.form-row input,.form-row select{padding:10px 12px;font-size:14px}
.loan-item{padding:8px 14px 8px 28px}
.loan-item .iamt{font-size:12px}
.loan-actions-popup{padding:6px 14px;flex-wrap:wrap}
.stats{grid-template-columns:repeat(2,1fr);gap:8px}
.stat-card{padding:12px}
.stat-card .val{font-size:16px}
.month-header{padding:10px 14px}
.month-header .total{font-size:13px}
.loan-row .right{width:90px}
.loan-row .pay-amt{font-size:13px}
.modal{padding:20px;width:95%;max-width:none}
.modal h3{font-size:16px;margin-bottom:14px}
.auth-card{width:100%;max-width:360px;padding:32px 24px;margin:16px}
.auth-card h2{font-size:20px;margin-bottom:24px}
.btn-row{flex-direction:column;gap:8px}
.btn{padding:12px 0}
.toast{max-width:calc(100vw - 32px);font-size:12px;padding:10px 16px}
}
@media(max-width:480px){
.header-inner{padding:0 10px;height:44px}
.header h1{font-size:14px}
.nav-tab{padding:4px 10px;font-size:11px}
.header-right a,.hdr-btn{padding:4px 6px;font-size:10px}
.main{padding:10px;gap:10px}
.panel-title{padding:10px 12px}
.panel-body{padding:10px 12px}
.stats{grid-template-columns:1fr 1fr}
.stat-card{padding:10px}
.stat-card .lbl{font-size:10px}
.stat-card .val{font-size:14px}
.form-grid{gap:8px}
.loan-group-header{padding:10px 12px}
.loan-group-header .gname{font-size:14px}
.loan-item{padding:8px 12px}
.month-header{padding:8px 12px}
.month-header .ym{font-size:13px}
.month-detail{padding:0 12px 8px}
.auth-card{padding:24px 20px}
.auth-card h2{font-size:18px;margin-bottom:20px}
}
</style> </style>
</head> </head>
<body> <body>
<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="请输入密码"></div>
<div id="loginError" class="error-msg"></div>
<div class="btn-row"><button class="btn btn-main" onclick="doLogin()">登录</button></div>
<div class="links"><a href="#" onclick="showRegister()">注册新账户</a></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">
<div class="header-inner"> <div class="header-inner">
<h1>债务统计</h1> <h1>债务统计</h1>
@@ -189,11 +143,10 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
<div class="notif-list" id="notifList"><div class="notif-empty">暂无通知</div></div> <div class="notif-list" id="notifList"><div class="notif-empty">暂无通知</div></div>
</div> </div>
</div> </div>
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
<div class="export-wrap"> <div class="export-wrap">
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
<div class="export-menu" id="userMenu"> <div class="export-menu" id="userMenu">
<a href="#" onclick="showProfile()">个人中心</a> <a href="#" onclick="showProfile()">个人中心</a>
<a href="#" onclick="showChangePwd(event)">修改密码</a>
</div> </div>
</div> </div>
<a href="/admin.html" class="admin-link" id="adminLink" style="display:none">管理</a> <a href="/admin.html" class="admin-link" id="adminLink" style="display:none">管理</a>
@@ -205,13 +158,6 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
<a href="#" onclick="exportHTML(event)">导出 HTML</a> <a href="#" onclick="exportHTML(event)">导出 HTML</a>
</div> </div>
</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> <a href="#" class="logout-link" onclick="doLogout()">退出</a>
</div> </div>
</div> </div>
@@ -227,6 +173,45 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
<div class="chart-card full-width"><h3>月度还款计划</h3><div class="chart-wrap"><canvas id="chartMonth"></canvas></div></div> <div class="chart-card full-width"><h3>月度还款计划</h3><div class="chart-wrap"><canvas id="chartMonth"></canvas></div></div>
</div> </div>
</div> </div>
</div>
<div id="twofaBindModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9998;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:16px;padding:28px;max-width:360px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.2)">
<h3>双因素认证</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:12px;text-align:center">请使用验证器应用扫描二维码</p>
<div style="display:flex;justify-content:center;margin:16px 0"><img id="twofaBindQr" width="200" height="200" style="border:1px solid var(--border);border-radius:8px"></div>
<p style="font-size:11px;color:var(--text3);text-align:center;margin-bottom:12px">密钥:<code id="twofaBindSecret" style="background:#f5f5f7;padding:2px 6px;border-radius:4px;font-size:11px"></code></p>
<div style="display:flex;gap:8px;align-items:center"><input type="text" id="twofaBindCode" placeholder="输入 6 位验证码" maxlength="6" style="flex:1;padding:10px;border:1px solid #d2d2d7;border-radius:8px;font-size:20px;text-align:center;letter-spacing:4px"></div>
<div id="twofaBindError" class="error-msg" style="display:none"></div>
<div style="margin-top:16px"><button class="btn btn-main" style="width:100%" onclick="verifyTwofaBind()">确认绑定</button></div>
</div>
</div>
<div id="profileModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9998;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:16px;padding:28px;max-width:480px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.2)">
<h3>个人中心</h3>
<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="请输入验证码"></div>
<div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div>
<div style="margin-top:16px;padding-top:16px;border-top:1px solid var(--border)">
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">双因素认证</label>
<div id="twofaStatus"><span style="font-size:12px;color:var(--text3)">加载中...</span></div>
<div id="twofaSetupSection" style="display:none">
<p style="font-size:12px;color:var(--text2);margin:12px 0">请使用验证器应用扫描:</p>
<div style="display:flex;justify-content:center;margin:12px 0"><img id="twofaQrCode" width="200" height="200" style="border:1px solid var(--border);border-radius:8px"></div>
<p style="font-size:11px;color:var(--text3);text-align:center;margin-bottom:12px">密钥:<code id="twofaSecret" style="background:#f5f5f7;padding:2px 6px;border-radius:4px"></code></p>
<div style="display:flex;gap:8px;align-items:center"><input type="text" id="twofaSetupCode" placeholder="6位验证码" maxlength="6" style="flex:1"><button class="btn btn-main" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="verifyTwofaSetup()">确认</button></div>
</div>
</div>
<div style="margin-top:16px;display:flex;gap:10px"><button class="btn-ghost" onclick="closeProfile()" style="flex:1">取消</button><button class="btn btn-main" onclick="saveProfile()" style="flex:1">保存</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-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-ghost" onclick="closeChangePwd()">取消</button><button class="btn btn-main" onclick="doChangePwd()">确认修改</button></div></div></div>
<script> <script>
const API='/api/v1'; const API='/api/v1';
@@ -236,6 +221,7 @@ let currentUser=null;
let loans=[]; let loans=[];
const $=id=>document.getElementById(id); const $=id=>document.getElementById(id);
const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2}); const fmt=n=>n.toLocaleString('zh-CN',{minimumFractionDigits:2,maximumFractionDigits:2});
function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
async function apiFetch(path,opts={}){ async function apiFetch(path,opts={}){
const headers={'Content-Type':'application/json',...opts.headers}; const headers={'Content-Type':'application/json',...opts.headers};
@@ -250,14 +236,7 @@ async function apiFetch(path,opts={}){
} }
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');} 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='';}}
function showProfile(){ function showProfile(){
$('userMenu').classList.remove('show'); $('userMenu').classList.remove('show');
@@ -265,70 +244,128 @@ function showProfile(){
$('profileNickname').value=currentUser.nickname||''; $('profileNickname').value=currentUser.nickname||'';
$('profileEmail').value=currentUser.real_email||''; $('profileEmail').value=currentUser.real_email||'';
$('emailCodeRow').style.display='none'; $('emailCodeRow').style.display='none';
$('profileEmailCode').value='';
$('profileError').style.display='none'; $('profileError').style.display='none';
$('profileSuccess').style.display='none'; $('profileSuccess').style.display='none';
$('profileModal').classList.add('show'); $('profileModal').style.display='flex';
loadTwofaStatus();
} }
function closeProfile(){$('profileModal').classList.remove('show');} function closeProfile(){$('profileModal').style.display='none';}
let emailVerifySent=false; let emailVerifySent=false;
async function sendEmailVerify(){ async function sendEmailVerify(){
const email=$('profileEmail').value.trim(); const email=$('profileEmail').value.trim();
if(!email||!email.includes('@')){showProfileError('请输入有效邮箱');return;} if(!email||!email.includes('@')){$('profileError').textContent='请输入有效邮箱';$('profileError').style.display='';return;}
$('profileError').style.display='none'; $('profileError').style.display='none';
const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})}); const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})});
if(res&&res.ok){ if(res&&res.ok){emailVerifySent=true;$('emailCodeRow').style.display='';$('profileSuccess').style.display='';$('profileSuccess').textContent='验证码已发送';}
emailVerifySent=true; else{const d=await res.json().catch(()=>({}));$('profileError').textContent=d.detail||'发送失败';$('profileError').style.display='';}
$('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(){ async function saveProfile(){
const nick=$('profileNickname').value.trim(); const nick=$('profileNickname').value.trim();
const email=$('profileEmail').value.trim(); const email=$('profileEmail').value.trim();
const code=$('profileEmailCode').value.trim(); const code=$('profileEmailCode').value.trim();
$('profileError').style.display='none';
hideProfileError();
$('profileSuccess').style.display='none'; $('profileSuccess').style.display='none';
if(nick&&nick!==currentUser.nickname){
// Update nickname
if(nick!==currentUser.nickname){
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})}); 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;} if(res&&res.ok){currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'昵称修改失败');return;} else{$('profileError').textContent='昵称修改失败';$('profileError').style.display='';return;}
} }
// Update email if changed and code provided
if(email&&email!==currentUser.real_email){ if(email&&email!==currentUser.real_email){
if(!code){showProfileError('请输入邮箱验证码');$('emailCodeRow').style.display='';return;} if(!code){$('profileError').textContent='请输入邮箱验证码';$('profileError').style.display='';return;}
const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})}); const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})});
if(res&&res.ok){currentUser.real_email=email;} if(res&&res.ok){currentUser.real_email=email;}
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'邮箱修改失败');return;} else{$('profileError').textContent='邮箱修改失败';$('profileError').style.display='';return;}
} }
$('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功'; $('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功';
setTimeout(()=>{closeProfile();},1000); setTimeout(closeProfile,1000);
}
async function loadTwofaStatus(){
const res=await apiFetch('/auth/2fa/status');
if(res&&res.ok){
const data=await res.json();
const el=document.getElementById('twofaStatus');
if(data.enabled){el.innerHTML='<span style="font-size:12px;color:var(--green)">已启用</span>';}
else{el.innerHTML='<span style="font-size:12px;color:var(--text3)">未启用</span>';}
}
}
function toggleNotifDropdown(e){e.stopPropagation();$('notifDropdown').classList.toggle('show');}
document.addEventListener('click',function(){$('notifDropdown')?.classList.remove('show');$('userMenu')?.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})"><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('');}
function notifTypeIcon(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)+'天前';}
async function readNotif(id){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();}
async function markAllNotifRead(){await apiFetch('/notifications/read-all',{method:'PUT'});loadNotifications();}
let twofaBindToken='';
function showTwofaBindModal(){
apiFetch('/auth/2fa/status').then(function(res){
if(res&&res.ok){res.json().then(function(data){
if(data.enabled&&data.has_secret){return;}
apiFetch('/auth/2fa/setup',{method:'POST'}).then(function(res2){
if(res2&&res2.ok){res2.json().then(function(d){
$('twofaBindQr').src=d.qr_code;
$('twofaBindSecret').textContent=d.secret;
$('twofaBindCode').value='';
$('twofaBindError').style.display='none';
$('twofaBindModal').style.display='flex';
document.body.style.overflow='hidden';
});}
});
});}
});
}
function closeTwofaBindModal(){$('twofaBindModal').style.display='none';document.body.style.overflow='';}
async function verifyTwofaBind(){
var code=$('twofaBindCode').value.trim();
if(!code||code.length!==6){$('twofaBindError').textContent='请输入6位验证码';$('twofaBindError').style.display='';return;}
var res=await apiFetch('/auth/2fa/verify',{method:'POST',body:JSON.stringify({code:code})});
if(res&&res.ok){$('twofaBindModal').style.display='none';document.body.style.overflow='';showToast('双因素认证已绑定','success');}
else{var d=await res.json().catch(function(){return {};});$('twofaBindError').textContent=d.detail||'验证码错误';$('twofaBindError').style.display='';}
}
function showToast(msg,type){alert(msg);}
async function doLogin(){
var account=$('loginEmail').value.trim();
var pwd=$('loginPwd').value;
if(!account||!pwd){$('loginError').textContent='请输入账号和密码';$('loginError').style.display='';return;}
$('loginError').style.display='none';
try{
var res=await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:account,password:pwd})});
var data=await res.json();
if(!res.ok){$('loginError').textContent=data.detail||'登录失败';$('loginError').style.display='';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){$('loginError').textContent='网络异常';$('loginError').style.display='';}
} }
async function checkAuth(){ async function checkAuth(){
if(!token){window.location.href='/';return false;} if(!token){showLogin();return false;}
const res=await apiFetch('/auth/profile'); const res=await apiFetch('/auth/profile');
if(!res||!res.ok){window.location.href='/';return false;} if(!res||!res.ok){showLogin();return false;}
currentUser=await res.json(); currentUser=await res.json();
$('userInfo').textContent=currentUser.nickname||currentUser.account; $('userInfo').textContent=currentUser.nickname||currentUser.account;
if(currentUser.role==='admin')$('adminLink').style.display=''; if(currentUser.role==='admin')$('adminLink').style.display='';
return true; showApp();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 doLogout(){token='';refreshToken='';currentUser=null;localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');window.location.href='/';} async function doRegister(){
var account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value;
if(!account||!pwd){$('regError').textContent='请填写账号和密码';return;}
if(pwd!==pwd2){$('regError').textContent='两次密码不一致';return;}
try{const res=await fetch(API+'/auth/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account: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 loadLoans(){const res=await apiFetch('/loans');if(!res||!res.ok)return;loans=await res.json();renderAll();}
@@ -336,149 +373,45 @@ function renderAll(){
if(!loans.length){$('statsRow').innerHTML='<div class="empty-tip">暂无数据</div>';return;} if(!loans.length){$('statsRow').innerHTML='<div class="empty-tip">暂无数据</div>';return;}
renderStats();renderPie();renderCreditor();renderMethod();renderRate();renderMonth(); renderStats();renderPie();renderCreditor();renderMethod();renderRate();renderMonth();
} }
function renderStats(){ function renderStats(){
let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0; let totalAmt=0,totalInt=0,totalPaid=0,paidN=0,totalN=0;
loans.forEach(l=>{ loans.forEach(l=>{totalAmt+=l.amount;totalN+=l.periods;totalInt+=l.schedule.reduce((a,r)=>a+r.it,0);paidN+=l.paid.length;totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});
totalAmt+=l.amount;totalN+=l.periods;
totalInt+=l.schedule.reduce((a,r)=>a+r.it,0);
paidN+=l.paid.length;
totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);
});
const totalPay=totalAmt+totalInt; const totalPay=totalAmt+totalInt;
$('statsRow').innerHTML=` $('statsRow').innerHTML=`<div class="stat-card"><div class="lbl">借款总额</div><div class="val blue">¥${fmt(totalAmt)}</div></div><div class="stat-card"><div class="lbl">总利息</div><div class="val red">¥${fmt(totalInt)}</div></div><div class="stat-card"><div class="lbl">已还</div><div class="val green">¥${fmt(totalPaid)}</div></div><div class="stat-card"><div class="lbl">还款进度</div><div class="val orange">${paidN}/${totalN}期</div></div>`;
<div class="stat-card"><div class="lbl">借款总额</div><div class="val blue">¥${fmt(totalAmt)}</div></div>
<div class="stat-card"><div class="lbl">总利息</div><div class="val red">¥${fmt(totalInt)}</div></div>
<div class="stat-card"><div class="lbl">已还</div><div class="val green">¥${fmt(totalPaid)}</div></div>
<div class="stat-card"><div class="lbl">还款进度</div><div class="val orange">${paidN}/${totalN}期</div></div>`;
} }
function renderPie(){ function renderPie(){
let totalAmt=0,totalPaid=0; let totalAmt=0,totalPaid=0;
loans.forEach(l=>{ loans.forEach(l=>{totalAmt+=l.amount;totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);});
totalAmt+=l.amount;
totalPaid+=l.schedule.filter(r=>l.paid.includes(r.p)).reduce((a,r)=>a+r.pay,0);
});
const remain=Math.max(0,totalAmt+loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid);
new Chart($('chartPie'),{type:'doughnut',data:{labels:['已还本金','待还本金','已还利息','待还利息'],datasets:[{data:[totalPaid*0.85,Math.max(0,totalAmt-totalPaid*0.85),totalPaid*0.15,Math.max(0,loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid*0.15)],backgroundColor:['#34c759','#0071e3','#30d158','#5ac8fa']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}}); new Chart($('chartPie'),{type:'doughnut',data:{labels:['已还本金','待还本金','已还利息','待还利息'],datasets:[{data:[totalPaid*0.85,Math.max(0,totalAmt-totalPaid*0.85),totalPaid*0.15,Math.max(0,loans.reduce((s,l)=>s+l.schedule.reduce((a,r)=>a+r.it,0),0)-totalPaid*0.15)],backgroundColor:['#34c759','#0071e3','#30d158','#5ac8fa']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}});
} }
function renderCreditor(){ function renderCreditor(){
const map={};loans.forEach(l=>{map[l.name]=(map[l.name]||0)+l.amount;}); const map={};loans.forEach(l=>{map[l.name]=(map[l.name]||0)+l.amount;});
const sorted=Object.entries(map).sort((a,b)=>b[1]-a[1]).slice(0,8); const sorted=Object.entries(map).sort((a,b)=>b[1]-a[1]).slice(0,8);
new Chart($('chartCreditor'),{type:'bar',data:{labels:sorted.map(s=>s[0]),datasets:[{label:'借款金额',data:sorted.map(s=>s[1]),backgroundColor:['#0071e3','#34c759','#ff9500','#af52de','#ff3b30','#5ac8fa','#30d158','#ff6b35']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:11}}}}}}); new Chart($('chartCreditor'),{type:'bar',data:{labels:sorted.map(s=>s[0]),datasets:[{label:'借款金额',data:sorted.map(s=>s[1]),backgroundColor:['#0071e3','#34c759','#ff9500','#af52de','#ff3b30','#5ac8fa','#30d158','#ff6b35']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:11}}}}}});
} }
function renderMethod(){ function renderMethod(){
const map={};loans.forEach(l=>{const m=l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本';map[m]=(map[m]||0)+1;}); const map={};loans.forEach(l=>{const m=l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本';map[m]=(map[m]||0)+1;});
new Chart($('chartMethod'),{type:'doughnut',data:{labels:Object.keys(map),datasets:[{data:Object.values(map),backgroundColor:['#0071e3','#ff9500','#af52de']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}}); new Chart($('chartMethod'),{type:'doughnut',data:{labels:Object.keys(map),datasets:[{data:Object.values(map),backgroundColor:['#0071e3','#ff9500','#af52de']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}}}});
} }
function renderRate(){ function renderRate(){
const buckets={'0%':0,'0-5%':0,'5-10%':0,'10-15%':0,'15-20%':0,'20%+':0}; const buckets={'0%':0,'0-5%':0,'5-10%':0,'10-15%':0,'15-20%':0,'20%+':0};
loans.forEach(l=>{ loans.forEach(l=>{const r=l.rate;if(r===0)buckets['0%']++;else if(r<5)buckets['0-5%']++;else if(r<10)buckets['5-10%']++;else if(r<15)buckets['10-15%']++;else if(r<20)buckets['15-20%']++;else buckets['20%+']++;});
const r=l.rate;
if(r===0)buckets['0%']++;
else if(r<5)buckets['0-5%']++;
else if(r<10)buckets['5-10%']++;
else if(r<15)buckets['10-15%']++;
else if(r<20)buckets['15-20%']++;
else buckets['20%+']++;
});
new Chart($('chartRate'),{type:'bar',data:{labels:Object.keys(buckets),datasets:[{label:'借款数量',data:Object.values(buckets),backgroundColor:['#34c759','#5ac8fa','#0071e3','#ff9500','#ff6b35','#ff3b30']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{stepSize:1}},x:{ticks:{font:{size:11}}}}}}); new Chart($('chartRate'),{type:'bar',data:{labels:Object.keys(buckets),datasets:[{label:'借款数量',data:Object.values(buckets),backgroundColor:['#34c759','#5ac8fa','#0071e3','#ff9500','#ff6b35','#ff3b30']}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false}},scales:{y:{beginAtZero:true,ticks:{stepSize:1}},x:{ticks:{font:{size:11}}}}}});
} }
function renderMonth(){ function renderMonth(){
const map={};loans.forEach(l=>{l.schedule.forEach(r=>{const ym=r.date.slice(0,7);if(!map[ym])map[ym]={total:0,paid:0};map[ym].total+=r.pay;if(l.paid.includes(r.p))map[ym].paid+=r.pay;});}); const map={};loans.forEach(l=>{l.schedule.forEach(r=>{const ym=r.date.slice(0,7);if(!map[ym])map[ym]={total:0,paid:0};map[ym].total+=r.pay;if(l.paid.includes(r.p))map[ym].paid+=r.pay;});});
const months=Object.keys(map).sort().slice(0,12); const months=Object.keys(map).sort().slice(0,12);
new Chart($('chartMonth'),{type:'bar',data:{labels:months.map(m=>m.replace('-','年')+'月'),datasets:[{label:'应还',data:months.map(m=>map[m].total),backgroundColor:'rgba(0,113,227,.3)',borderColor:'#0071e3',borderWidth:1},{label:'已还',data:months.map(m=>map[m].paid),backgroundColor:'rgba(52,199,89,.5)',borderColor:'#34c759',borderWidth:1}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:10}}}}}}); new Chart($('chartMonth'),{type:'bar',data:{labels:months.map(m=>m.replace('-','年')+'月'),datasets:[{label:'应还',data:months.map(m=>map[m].total),backgroundColor:'rgba(0,113,227,.3)',borderColor:'#0071e3',borderWidth:1},{label:'已还',data:months.map(m=>map[m].paid),backgroundColor:'rgba(52,199,89,.5)',borderColor:'#34c759',borderWidth:1}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{position:'bottom',labels:{padding:12,font:{size:12}}}},scales:{y:{beginAtZero:true,ticks:{callback:v=>'¥'+v.toLocaleString()}},x:{ticks:{font:{size:10}}}}}});
} }
function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function notifTypeIcon(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');$('exportMenu')?.classList.remove('show');$('importMenu')?.classList.remove('show');$('userMenu')?.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})"><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){await apiFetch('/notifications/'+id+'/read',{method:'PUT'});loadNotifications();}
function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');} function toggleExport(e){e.stopPropagation();$('exportMenu').classList.toggle('show');}
function toggleImport(e){e.stopPropagation();$('importMenu').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 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();if(!loans.length)return;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+=`${escapeHtml(l.name)},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本'},${paid},${status},${escapeHtml(l.purpose||'')}\n`;});download('债务统计_'+new Date().toISOString().slice(0,10)+'.csv',csv,'text/csv;charset=utf-8');}
function exportCSV(e){e.preventDefault();if(!loans.length)return;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},${l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本'},${paid},${status}\n`;});download('债务统计_'+new Date().toISOString().slice(0,10)+'.csv',csv,'text/csv;charset=utf-8');}
function exportJSON(e){e.preventDefault();if(!loans.length)return;const data=loans.map(l=>({债权人:l.name,借款日期:l.date,借款金额:l.amount,年化率:l.rate,分期数:l.periods,还款方式:l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本',已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',用途:l.purpose||''}));download('债务统计_'+new Date().toISOString().slice(0,10)+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');} function exportJSON(e){e.preventDefault();if(!loans.length)return;const data=loans.map(l=>({债权人:l.name,借款日期:l.date,借款金额:l.amount,年化率:l.rate,分期数:l.periods,还款方式:l.method==='e'?'等额本息':l.method==='p'?'等额本金':'先息后本',已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',用途:l.purpose||''}));download('债务统计_'+new Date().toISOString().slice(0,10)+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
function exportHTML(e){e.preventDefault();if(!loans.length)return;let h=`<html><head><meta charset="utf-8"><title>债务统计</title></head><body><h2>债务统计报表</h2><table border="1"><tr><th>借款人</th><th>金额</th><th>利率</th><th>期数</th></tr>`;loans.forEach(l=>{h+=`<tr><td>${escapeHtml(l.name)}</td><td>¥${l.amount.toLocaleString()}</td><td>${l.rate}%</td><td>${l.paid.length}/${l.periods}</td></tr>`;});h+=`</table> function exportHTML(e){e.preventDefault();if(!loans.length)return;let h='<html><head><meta charset="utf-8"><title>债务统计</title></head><body><h2>债务统计报表</h2><table border="1"><tr><th>借款人</th><th>金额</th><th>利率</th><th>期数</th></tr>';loans.forEach(l=>{h+=`<tr><td>${escapeHtml(l.name)}</td><td>¥${l.amount.toLocaleString()}</td><td>${l.rate}%</td><td>${l.paid.length}/${l.periods}</td></tr>`;});h+='</table></body></html>';download('债务统计_'+new Date().toISOString().slice(0,10)+'.html',h,'text/html;charset=utf-8');}
<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="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('债务统计_'+new Date().toISOString().slice(0,10)+'.html',h,'text/html;charset=utf-8');}
function downloadTemplate(e){e.preventDefault();$('importMenu').classList.remove('show');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式\n';csv+='张三,2026-01-01,100000,5.7,5198.99,24,等额本息\n';download('导入模板.csv',csv,'text/csv;charset=utf-8');}
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');}
checkAuth().then(ok=>{if(ok)loadLoans();}); checkAuth().then(ok=>{if(ok)loadLoans();});
</script> </script>
<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="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> </body>
</html> </html>

View File

@@ -361,6 +361,17 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
.auth-card h2{font-size:18px;margin-bottom:20px} .auth-card h2{font-size:18px;margin-bottom:20px}
} }
/* 2FA */
.twofa-section{margin-top:16px;padding-top:16px;border-top:1px solid var(--border)}
.twofa-status{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.twofa-badge{padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600}
.twofa-badge.enabled{background:rgba(52,199,89,.1);color:var(--green)}
.twofa-badge.disabled{background:rgba(0,0,0,.04);color:var(--text3)}
.twofa-qr{display:flex;justify-content:center;margin:16px 0}
.twofa-qr img{border:1px solid var(--border);border-radius:8px}
.twofa-input{display:flex;gap:8px;align-items:center}
.twofa-input input{flex:1}
</style> </style>
</head> </head>
<body> <body>
@@ -527,6 +538,24 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
<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="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> <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>
<div id="twofaBindModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9998;align-items:center;justify-content:center">
<div style="background:#fff;border-radius:16px;padding:28px;max-width:360px;width:90%;box-shadow:0 24px 80px rgba(0,0,0,.2)">
<h3>双因素认证</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:12px;text-align:center">请使用验证器应用扫描二维码</p>
<div class="twofa-qr"><img id="twofaBindQr" width="200" height="200"></div>
<p style="font-size:11px;color:var(--text3);text-align:center;margin-bottom:12px">密钥:<code id="twofaBindSecret" style="background:#f5f5f7;padding:2px 6px;border-radius:4px"></code></p>
<div class="form-row">
<input type="text" id="twofaBindCode" placeholder="输入 6 位验证码" maxlength="6" style="text-align:center;font-size:20px;letter-spacing:4px">
</div>
<div id="twofaBindError" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-main" onclick="verifyTwofaBind()">确认绑定</button>
</div>
</div>
</div>
<script> <script>
const API='/api/v1'; const API='/api/v1';
let token=localStorage.getItem('access_token')||''; let token=localStorage.getItem('access_token')||'';
@@ -539,7 +568,7 @@ let openGroups=new Set();
let editId=null; let editId=null;
let hidePaidItems=true; let hidePaidItems=true;
let hidePaidMonths=true; let hidePaidMonths=true;
let hideOthers=true; let hideOthers=false;
let quickPayLoanId=null; let quickPayLoanId=null;
let actionLoanId=null; let actionLoanId=null;
@@ -592,6 +621,7 @@ function showProfile(){
$('profileError').style.display='none'; $('profileError').style.display='none';
$('profileSuccess').style.display='none'; $('profileSuccess').style.display='none';
$('profileModal').classList.add('show'); $('profileModal').classList.add('show');
loadTwofaStatus();
} }
function closeProfile(){$('profileModal').classList.remove('show');} function closeProfile(){$('profileModal').classList.remove('show');}
@@ -641,6 +671,245 @@ async function saveProfile(){
setTimeout(()=>{closeProfile();},1000); setTimeout(()=>{closeProfile();},1000);
} }
// 2FA 相关函数
let twofaTempToken = '';
function closeTwofaModal() {
$('twofaModal').classList.remove('show');
twofaTempToken = '';
}
async function verifyTwofaLogin() {
const code = $('twofaCode').value.trim();
if (!code || code.length !== 6) {
$('twofaError').textContent = '请输入 6 位验证码';
$('twofaError').style.display = '';
return;
}
try {
const res = await fetch(API + '/auth/2fa/verify-login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({temp_token: twofaTempToken, code})
});
if (res.ok) {
const data = await res.json();
token = data.access_token;
refreshToken = data.refresh_token;
localStorage.setItem('access_token', token);
localStorage.setItem('refresh_token', refreshToken);
closeTwofaModal();
await checkAuth();
await loadLoans();
} else {
const data = await res.json().catch(() => ({}));
$('twofaError').textContent = data.detail || '验证失败';
$('twofaError').style.display = '';
}
} catch(e) {
$('twofaError').textContent = '网络异常';
$('twofaError').style.display = '';
}
}
// 修改 doLogin 处理 2FA
// 2FA 设置相关函数
async function loadTwofaStatus() {
const res = await apiFetch('/auth/2fa/status');
if (res && res.ok) {
const data = await res.json();
const statusEl = document.getElementById('twofaStatus');
if (statusEl) {
if (data.enabled) {
statusEl.innerHTML = '<span class="twofa-badge enabled">已启用</span><button class="btn btn-ghost" style="margin-left:8px;padding:4px 12px;font-size:11px" onclick="showDisableTwofa()">禁用</button>';
} else {
statusEl.innerHTML = '<span class="twofa-badge disabled">未启用</span><button class="btn btn-main" style="margin-left:8px;padding:4px 12px;font-size:11px" onclick="startSetupTwofa()">启用</button>';
}
}
}
}
let twofaSetupSecret = '';
let twofaSetupUrl = '';
async function startSetupTwofa() {
const res = await apiFetch('/auth/2fa/setup', {method: 'POST'});
if (res && res.ok) {
const data = await res.json();
twofaSetupSecret = data.secret;
twofaSetupUrl = data.otpauth_url;
const qrSection = document.getElementById('twofaSetupSection');
const qrImg = document.getElementById('twofaQrCode');
const secretText = document.getElementById('twofaSecret');
if (qrImg) qrImg.src = data.qr_code;
if (secretText) secretText.textContent = data.secret;
if (qrSection) qrSection.style.display = '';
}
}
async function verifyTwofaSetup() {
const code = $('twofaSetupCode').value.trim();
if (!code || code.length !== 6) {
alert('请输入 6 位验证码');
return;
}
const res = await apiFetch('/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({code})
});
if (res && res.ok) {
alert('双因素认证已启用');
$('twofaSetupSection').style.display = 'none';
loadTwofaStatus();
} else {
const data = await res.json().catch(() => ({}));
alert(data.detail || '验证失败');
}
}
async function showDisableTwofa() {
const code = prompt('请输入当前验证码以禁用 2FA');
if (!code) return;
const res = await apiFetch('/auth/2fa/disable', {
method: 'POST',
body: JSON.stringify({code})
});
if (res && res.ok) {
alert('双因素认证已禁用');
loadTwofaStatus();
} else {
const data = await res.json().catch(() => ({}));
alert(data.detail || '禁用失败');
}
}
let twofaBindToken = '';
function closeTwofaBindModal() {
$('twofaBindModal').classList.remove('show');
twofaBindToken = '';
}
async function verifyTwofaBind() {
var code = $('twofaBindCode').value.trim();
if (!code || code.length !== 6) {
$('twofaBindError').textContent = '请输入 6 位验证码';
$('twofaBindError').style.display = '';
return;
}
var res = await apiFetch('/auth/2fa/verify', {
method: 'POST',
body: JSON.stringify({code: code})
});
if (res && res.ok) {
twofaRequired = false;
$('twofaBindModal').style.display = 'none';
document.body.style.overflow = '';
showToast('双因素认证已绑定成功', 'success');
} else {
var data = await res.json().catch(function(){return {};});
$('twofaBindError').textContent = data.detail || '验证码错误';
$('twofaBindError').style.display = '';
}
}
function checkTwofaBinding(){
apiFetch('/auth/2fa/status').then(function(res){
if(res&&res.ok){
res.json().then(function(data){
if(!data.enabled){
// 需要绑定 2FA显示弹窗并阻止操作
showTwofaBindModal();
}
});
}
});
}
var twofaRequired=false;
function showTwofaBindModal(){
twofaRequired=true;
// 检查是否已经绑定
apiFetch('/auth/2fa/status').then(function(res){
if(res&&res.ok){
res.json().then(function(data){
if(data.enabled && data.has_secret){
// 已经绑定了,直接关闭
twofaRequired=false;
$('twofaBindModal').style.display='none';
document.body.style.overflow='';
return;
}
// 获取二维码
apiFetch('/auth/2fa/setup',{method:'POST'}).then(function(res2){
if(res2&&res2.ok){
res2.json().then(function(data2){
$('twofaBindQr').src=data2.qr_code;
$('twofaBindSecret').textContent=data2.secret;
$('twofaBindCode').value='';
$('twofaBindError').style.display='none';
$('twofaBindModal').style.display='flex';
document.body.style.overflow='hidden';
});
}
});
});
}
});
}
function closeTwofaBindModal(){
if(!twofaRequired) return; // 如果必须绑定,不允许关闭
$('twofaBindModal').style.display='none';
document.body.style.overflow='';
}
async function doLogin(){
var account = $('loginEmail').value.trim();
var pwd = $('loginPwd').value;
if(!account||!pwd){$('loginError').textContent='请输入账号和密码';$('loginError').style.display='';return;}
$('loginError').style.display='none';
try{
var res = await fetch(API+'/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account:account,password:pwd})});
var data = await res.json();
if(!res.ok){
if(res.status===429&&data.retry_after){startLoginCountdown(data.retry_after);}
else{$('loginError').textContent=data.detail||'登录失败';$('loginError').style.display='';}
return;
}
token=data.access_token;
refreshToken=data.refresh_token;
localStorage.setItem('access_token',token);
localStorage.setItem('refresh_token',refreshToken);
await checkAuth();
await loadLoans();
// 登录后检查是否需要绑定 2FA
checkTwofaBinding();
}catch(e){
$('loginError').textContent='网络连接异常,请稍后重试';
$('loginError').style.display='';
}
}
async function checkAuth(){ async function checkAuth(){
if(!token){showLogin();return false;} if(!token){showLogin();return false;}
const res=await apiFetch('/auth/profile'); const res=await apiFetch('/auth/profile');
@@ -674,12 +943,7 @@ function startLoginCountdown(seconds){
tick(); tick();
loginCountdown=setInterval(tick,1000); 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(){ async function doRegister(){
const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value; const account=$('regEmail').value.trim(),nick=$('regNick').value.trim(),pwd=$('regPwd').value,pwd2=$('regPwd2').value;
if(!account||!pwd)return $('regError').textContent='请填写账号和密码'; if(!account||!pwd)return $('regError').textContent='请填写账号和密码';
@@ -852,8 +1116,21 @@ function exportHTML(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.leng
.auth-card h2{font-size:18px;margin-bottom:20px} .auth-card h2{font-size:18px;margin-bottom:20px}
} }
</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"> /* 2FA */
.twofa-section{margin-top:16px;padding-top:16px;border-top:1px solid var(--border)}
.twofa-status{display:flex;align-items:center;gap:8px;margin-bottom:12px}
.twofa-badge{padding:4px 10px;border-radius:12px;font-size:11px;font-weight:600}
.twofa-badge.enabled{background:rgba(52,199,89,.1);color:var(--green)}
.twofa-badge.disabled{background:rgba(0,0,0,.04);color:var(--text3)}
.twofa-qr{display:flex;justify-content:center;margin:16px 0}
.twofa-qr img{border:1px solid var(--border);border-radius:8px}
.twofa-input{display:flex;gap:8px;align-items:center}
.twofa-input input{flex:1}
</style>
<div id="profileModal" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.5);z-index:9998;align-items:center;justify-content:center">
<div class="modal" style="max-width:480px"> <div class="modal" style="max-width:480px">
<h3>个人中心</h3> <h3>个人中心</h3>
<div class="profile-section"> <div class="profile-section">
@@ -870,6 +1147,26 @@ function exportHTML(e){e.preventDefault();const vl=getVisibleLoans();if(!vl.leng
<label>验证码</label> <label>验证码</label>
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6"> <input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
</div> </div>
<div class="twofa-section">
<label style="display:block;font-size:13px;font-weight:500;margin-bottom:8px">双因素认证 (2FA)</label>
<div class="twofa-status" id="twofaStatus">
<span class="twofa-badge disabled">加载中...</span>
</div>
<div id="twofaSetupSection" style="display:none">
<p style="font-size:12px;color:var(--text2);margin-bottom:12px">请使用 Google Authenticator 等应用扫描下方二维码:</p>
<div class="twofa-qr"><img id="twofaQrCode" width="200" height="200"></div>
<p style="font-size:11px;color:var(--text3);text-align:center;margin-bottom:12px">或手动输入密钥:<code id="twofaSecret" style="background:#f5f5f7;padding:2px 6px;border-radius:4px"></code></p>
<div class="form-row">
<label>验证码</label>
<div class="twofa-input">
<input type="text" id="twofaSetupCode" placeholder="输入 6 位验证码" maxlength="6">
<button class="btn btn-main" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="verifyTwofaSetup()">确认启用</button>
</div>
</div>
</div>
</div>
<div id="profileError" class="error-msg" style="display:none"></div> <div id="profileError" class="error-msg" style="display:none"></div>
<div id="profileSuccess" class="success-msg" style="display:none"></div> <div id="profileSuccess" class="success-msg" style="display:none"></div>
<div class="btn-row"> <div class="btn-row">
@@ -951,32 +1248,3 @@ async function doForgotStep3(){
checkAuth().then(ok=>{if(ok)loadLoans();}); checkAuth().then(ok=>{if(ok)loadLoans();});
</script> </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>