v2.4.1: 修复 2FA 登录和前端数据加载问题

- 修复登录接口 response_model 导致 2FA 返回格式验证失败(500错误)
- 修复 showProfile 未调用 loadTwofaStatus 导致 2FA 状态一直显示加载中
- 修复 hideOthers 默认值为 false 确保管理员可查看所有数据
- 删除重复的 doLogin 函数
- 删除残留的代码片段修复 JavaScript 语法错误
This commit is contained in:
OP
2026-07-02 18:04:39 +08:00
parent 056c4defec
commit a0c57e0f21
6 changed files with 436 additions and 10 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

@@ -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>
@@ -539,7 +550,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 +603,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 +653,158 @@ async function saveProfile(){
setTimeout(()=>{closeProfile();},1000); setTimeout(()=>{closeProfile();},1000);
} }
// 2FA 相关函数
let twofaTempToken = '';
function showTwofaModal(tempToken) {
twofaTempToken = tempToken;
$('twofaCode').value = '';
$('twofaError').style.display = 'none';
$('twofaModal').classList.add('show');
}
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 || '禁用失败');
}
}
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;
}
// 检查是否需要 2FA
if(data.require_2fa){
showTwofaModal(data.temp_token);
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 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 +838,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,7 +1011,34 @@ 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}
} }
/* 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></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+=` </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="twofaModal">
<div class="modal">
<h3>双因素认证</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:16px;text-align:center">请输入手机验证器应用中的 6 位验证码</p>
<div class="form-row">
<input type="text" id="twofaCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:24px;letter-spacing:8px;font-weight:600">
</div>
<div id="twofaError" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeTwofaModal()">取消</button>
<button class="btn btn-main" onclick="verifyTwofaLogin()">验证</button>
</div>
</div>
</div>
<div class="modal-mask" id="profileModal"> <div class="modal-mask" id="profileModal">
<div class="modal" style="max-width:480px"> <div class="modal" style="max-width:480px">
<h3>个人中心</h3> <h3>个人中心</h3>
@@ -870,6 +1056,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">
@@ -952,6 +1158,22 @@ async function doForgotStep3(){
checkAuth().then(ok=>{if(ok)loadLoans();}); checkAuth().then(ok=>{if(ok)loadLoans();});
</script> </script>
<div class="modal-mask" id="twofaModal">
<div class="modal">
<h3>双因素认证</h3>
<p style="font-size:13px;color:var(--text2);margin-bottom:16px;text-align:center">请输入手机验证器应用中的 6 位验证码</p>
<div class="form-row">
<input type="text" id="twofaCode" placeholder="000000" maxlength="6" style="text-align:center;font-size:24px;letter-spacing:8px;font-weight:600">
</div>
<div id="twofaError" class="error-msg" style="display:none"></div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="closeTwofaModal()">取消</button>
<button class="btn btn-main" onclick="verifyTwofaLogin()">验证</button>
</div>
</div>
</div>
<div class="modal-mask" id="profileModal"> <div class="modal-mask" id="profileModal">
<div class="modal" style="max-width:480px"> <div class="modal" style="max-width:480px">
<h3>个人中心</h3> <h3>个人中心</h3>
@@ -969,6 +1191,26 @@ checkAuth().then(ok=>{if(ok)loadLoans();});
<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">