feat: 新增个人中心功能
- 修改昵称改为个人中心 - 个人中心包含:账号(只读)、昵称、邮箱 - 修改邮箱需要旧邮箱验证码验证 - 后端新增 /auth/verify-email 和 /auth/change-email API - 两个页面同步更新
This commit is contained in:
@@ -129,6 +129,14 @@ async def list_groups(db: AsyncSession = Depends(get_db)):
|
|||||||
return [{"id": g.id, "name": g.name} for g in result.scalars().all()]
|
return [{"id": g.id, "name": g.name} for g in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class EmailVerifyRequest(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
class EmailChangeRequest(BaseModel):
|
||||||
|
code: str
|
||||||
|
new_email: str
|
||||||
class ProfileUpdate(BaseModel):
|
class ProfileUpdate(BaseModel):
|
||||||
nickname: str | None = None
|
nickname: str | None = None
|
||||||
real_email: str | None = None
|
real_email: str | None = None
|
||||||
@@ -148,6 +156,83 @@ async def update_profile(
|
|||||||
return {"message": "资料更新成功"}
|
return {"message": "资料更新成功"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email")
|
||||||
|
async def send_email_verification(
|
||||||
|
req: EmailVerifyRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
import secrets
|
||||||
|
from datetime import timedelta
|
||||||
|
code = f"{secrets.randbelow(1000000):06d}"
|
||||||
|
key = f"email_verify:{user.id}"
|
||||||
|
value = f"{req.email}|{code}|{(datetime.utcnow() + timedelta(minutes=5)).isoformat()}"
|
||||||
|
|
||||||
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
if setting:
|
||||||
|
setting.value = value
|
||||||
|
else:
|
||||||
|
db.add(SystemSetting(key=key, value=value))
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
if user.real_email:
|
||||||
|
from app.services.email_service import send_email
|
||||||
|
html = f"""
|
||||||
|
<div style="font-family:-apple-system,sans-serif;max-width:480px;margin:0 auto;padding:32px">
|
||||||
|
<h2 style="color:#0071e3;margin-bottom:16px">邮箱变更验证码</h2>
|
||||||
|
<p>Hi {user.nickname or user.email},</p>
|
||||||
|
<p>您正在修改绑定邮箱,验证码如下:</p>
|
||||||
|
<div style="background:#f5f5f7;border-radius:12px;padding:24px;margin:16px 0;text-align:center">
|
||||||
|
<p style="font-size:36px;font-weight:700;letter-spacing:8px;color:#1d1d1f;margin:0">{code}</p>
|
||||||
|
<p style="color:#6e6e73;font-size:12px;margin:8px 0 0 0">5 分钟内有效</p>
|
||||||
|
</div>
|
||||||
|
<p style="color:#6e6e73;font-size:13px">如非本人操作,请忽略此邮件。</p>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
await send_email(user.real_email, "【债务管理系统】邮箱变更验证码", html)
|
||||||
|
|
||||||
|
return {"message": "验证码已发送到当前邮箱"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/change-email")
|
||||||
|
async def change_email(
|
||||||
|
req: EmailChangeRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
key = f"email_verify:{user.id}"
|
||||||
|
result = await db.execute(select(SystemSetting).where(SystemSetting.key == key))
|
||||||
|
setting = result.scalar_one_or_none()
|
||||||
|
if not setting:
|
||||||
|
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||||
|
|
||||||
|
parts = setting.value.split("|")
|
||||||
|
if len(parts) != 3 or parts[1] != req.code:
|
||||||
|
raise HTTPException(status_code=400, detail="验证码错误")
|
||||||
|
|
||||||
|
try:
|
||||||
|
expires_at = datetime.fromisoformat(parts[2])
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="验证码格式错误")
|
||||||
|
|
||||||
|
if datetime.utcnow() > expires_at:
|
||||||
|
await db.delete(setting)
|
||||||
|
await db.commit()
|
||||||
|
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||||||
|
|
||||||
|
# Check if new email is already used
|
||||||
|
existing = await db.execute(select(User).where(User.real_email == req.new_email, User.id != user.id))
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail="该邮箱已被其他账号使用")
|
||||||
|
|
||||||
|
user.real_email = req.new_email
|
||||||
|
await db.delete(setting)
|
||||||
|
await db.commit()
|
||||||
|
return {"message": "邮箱修改成功"}
|
||||||
|
|
||||||
@router.put("/password")
|
@router.put("/password")
|
||||||
async def change_password(
|
async def change_password(
|
||||||
req: ChangePasswordRequest,
|
req: ChangePasswordRequest,
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
|
|||||||
.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}
|
||||||
|
|
||||||
|
.profile-section{padding:4px 0}
|
||||||
|
.profile-section .form-row{margin-bottom:14px}
|
||||||
|
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
|
||||||
|
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
|
||||||
|
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
|
||||||
|
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -110,7 +117,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC"
|
|||||||
<div class="export-wrap">
|
<div class="export-wrap">
|
||||||
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
|
<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="showChangeNick(event)">修改昵称</a>
|
<a href="#" onclick="showProfile()">个人中心</a>
|
||||||
<a href="#" onclick="showChangePwd(event)">修改密码</a>
|
<a href="#" onclick="showChangePwd(event)">修改密码</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -176,6 +183,66 @@ function showChangePwd(e){if(e)e.preventDefault();$('userMenu').classList.remove
|
|||||||
function closeChangePwd(){$('changePwdModal').classList.remove('show');}
|
function closeChangePwd(){$('changePwdModal').classList.remove('show');}
|
||||||
async function doChangePwd(){const old=$('oldPwd').value,nw=$('newPwd').value,nw2=$('newPwd2').value;if(!old||!nw)return $('pwdError').textContent='请填写所有字段';$('pwdError').style.display='';if(nw!==nw2){$('pwdError').textContent='两次新密码不一致';$('pwdError').style.display='';return;}$('pwdError').style.display='none';const res=await apiFetch('/auth/password',{method:'PUT',body:JSON.stringify({old_password:old,new_password:nw})});if(res&&res.ok){$('pwdSuccess').style.display='';$('pwdSuccess').textContent='密码修改成功';$('oldPwd').value='';$('newPwd').value='';$('newPwd2').value='';}else{const d=await res.json();$('pwdError').textContent=d.detail||'修改失败';$('pwdError').style.display='';}}
|
async function 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(){
|
||||||
|
$('userMenu').classList.remove('show');
|
||||||
|
$('profileAccount').value=currentUser.account;
|
||||||
|
$('profileNickname').value=currentUser.nickname||'';
|
||||||
|
$('profileEmail').value=currentUser.real_email||'';
|
||||||
|
$('emailCodeRow').style.display='none';
|
||||||
|
$('profileEmailCode').value='';
|
||||||
|
$('profileError').style.display='none';
|
||||||
|
$('profileSuccess').style.display='none';
|
||||||
|
$('profileModal').classList.add('show');
|
||||||
|
}
|
||||||
|
function closeProfile(){$('profileModal').classList.remove('show');}
|
||||||
|
|
||||||
|
let emailVerifySent=false;
|
||||||
|
async function sendEmailVerify(){
|
||||||
|
const email=$('profileEmail').value.trim();
|
||||||
|
if(!email||!email.includes('@')){showProfileError('请输入有效邮箱');return;}
|
||||||
|
$('profileError').style.display='none';
|
||||||
|
const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})});
|
||||||
|
if(res&&res.ok){
|
||||||
|
emailVerifySent=true;
|
||||||
|
$('emailCodeRow').style.display='';
|
||||||
|
$('profileSuccess').style.display='';$('profileSuccess').textContent='验证码已发送到当前邮箱';
|
||||||
|
}else{
|
||||||
|
const d=await res.json().catch(()=>({}));
|
||||||
|
showProfileError(d.detail||'发送失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProfileError(msg){$('profileError').textContent=msg;$('profileError').style.display='';}
|
||||||
|
function hideProfileError(){$('profileError').style.display='none';}
|
||||||
|
|
||||||
|
async function saveProfile(){
|
||||||
|
const nick=$('profileNickname').value.trim();
|
||||||
|
const email=$('profileEmail').value.trim();
|
||||||
|
const code=$('profileEmailCode').value.trim();
|
||||||
|
|
||||||
|
hideProfileError();
|
||||||
|
$('profileSuccess').style.display='none';
|
||||||
|
|
||||||
|
// Update nickname
|
||||||
|
if(nick!==currentUser.nickname){
|
||||||
|
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});
|
||||||
|
if(res&&res.ok){currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}
|
||||||
|
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'昵称修改失败');return;}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update email if changed and code provided
|
||||||
|
if(email&&email!==currentUser.real_email){
|
||||||
|
if(!code){showProfileError('请输入邮箱验证码');$('emailCodeRow').style.display='';return;}
|
||||||
|
const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})});
|
||||||
|
if(res&&res.ok){currentUser.real_email=email;}
|
||||||
|
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'邮箱修改失败');return;}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功';
|
||||||
|
setTimeout(()=>{closeProfile();},1000);
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAuth(){
|
async function checkAuth(){
|
||||||
if(!token){window.location.href='/';return false;}
|
if(!token){window.location.href='/';return false;}
|
||||||
const res=await apiFetch('/auth/profile');
|
const res=await apiFetch('/auth/profile');
|
||||||
@@ -273,6 +340,33 @@ function exportJSON(e){e.preventDefault();if(!loans.length)return;const data=loa
|
|||||||
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>
|
||||||
<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="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="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');}
|
</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 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');}
|
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');}
|
||||||
@@ -282,5 +376,32 @@ checkAuth().then(ok=>{if(ok)loadLoans();});
|
|||||||
|
|
||||||
<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="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="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>
|
||||||
|
|||||||
@@ -279,6 +279,13 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
|
|||||||
.notif-item .notif-msg{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
.notif-item .notif-msg{font-size:12px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
.notif-item .notif-time{font-size:11px;color:var(--text3);white-space:nowrap}
|
.notif-item .notif-time{font-size:11px;color:var(--text3);white-space:nowrap}
|
||||||
.notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
|
.notif-empty{text-align:center;padding:32px;color:var(--text3);font-size:13px}
|
||||||
|
|
||||||
|
.profile-section{padding:4px 0}
|
||||||
|
.profile-section .form-row{margin-bottom:14px}
|
||||||
|
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
|
||||||
|
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
|
||||||
|
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
|
||||||
|
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -376,7 +383,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text"
|
|||||||
<div class="export-wrap">
|
<div class="export-wrap">
|
||||||
<span class="user-info" id="userInfo" onclick="toggleUserMenu(event)"></span>
|
<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="showChangeNick(event)">修改昵称</a>
|
<a href="#" onclick="showProfile()">个人中心</a>
|
||||||
<a href="#" onclick="showChangePwd(event)">修改密码</a>
|
<a href="#" onclick="showChangePwd(event)">修改密码</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,6 +506,66 @@ async function apiFetch(path,opts={}){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
|
function toggleUserMenu(e){e.stopPropagation();$('userMenu').classList.toggle('show');}
|
||||||
|
|
||||||
|
function showProfile(){
|
||||||
|
$('userMenu').classList.remove('show');
|
||||||
|
$('profileAccount').value=currentUser.account;
|
||||||
|
$('profileNickname').value=currentUser.nickname||'';
|
||||||
|
$('profileEmail').value=currentUser.real_email||'';
|
||||||
|
$('emailCodeRow').style.display='none';
|
||||||
|
$('profileEmailCode').value='';
|
||||||
|
$('profileError').style.display='none';
|
||||||
|
$('profileSuccess').style.display='none';
|
||||||
|
$('profileModal').classList.add('show');
|
||||||
|
}
|
||||||
|
function closeProfile(){$('profileModal').classList.remove('show');}
|
||||||
|
|
||||||
|
let emailVerifySent=false;
|
||||||
|
async function sendEmailVerify(){
|
||||||
|
const email=$('profileEmail').value.trim();
|
||||||
|
if(!email||!email.includes('@')){showProfileError('请输入有效邮箱');return;}
|
||||||
|
$('profileError').style.display='none';
|
||||||
|
const res=await apiFetch('/auth/verify-email',{method:'POST',body:JSON.stringify({email})});
|
||||||
|
if(res&&res.ok){
|
||||||
|
emailVerifySent=true;
|
||||||
|
$('emailCodeRow').style.display='';
|
||||||
|
$('profileSuccess').style.display='';$('profileSuccess').textContent='验证码已发送到当前邮箱';
|
||||||
|
}else{
|
||||||
|
const d=await res.json().catch(()=>({}));
|
||||||
|
showProfileError(d.detail||'发送失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProfileError(msg){$('profileError').textContent=msg;$('profileError').style.display='';}
|
||||||
|
function hideProfileError(){$('profileError').style.display='none';}
|
||||||
|
|
||||||
|
async function saveProfile(){
|
||||||
|
const nick=$('profileNickname').value.trim();
|
||||||
|
const email=$('profileEmail').value.trim();
|
||||||
|
const code=$('profileEmailCode').value.trim();
|
||||||
|
|
||||||
|
hideProfileError();
|
||||||
|
$('profileSuccess').style.display='none';
|
||||||
|
|
||||||
|
// Update nickname
|
||||||
|
if(nick!==currentUser.nickname){
|
||||||
|
const res=await apiFetch('/auth/profile',{method:'PUT',body:JSON.stringify({nickname:nick})});
|
||||||
|
if(res&&res.ok){currentUser.nickname=nick;$('userInfo').textContent=nick||currentUser.account;}
|
||||||
|
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'昵称修改失败');return;}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update email if changed and code provided
|
||||||
|
if(email&&email!==currentUser.real_email){
|
||||||
|
if(!code){showProfileError('请输入邮箱验证码');$('emailCodeRow').style.display='';return;}
|
||||||
|
const res=await apiFetch('/auth/change-email',{method:'POST',body:JSON.stringify({code,new_email:email})});
|
||||||
|
if(res&&res.ok){currentUser.real_email=email;}
|
||||||
|
else{const d=await res.json().catch(()=>({}));showProfileError(d.detail||'邮箱修改失败');return;}
|
||||||
|
}
|
||||||
|
|
||||||
|
$('profileSuccess').style.display='';$('profileSuccess').textContent='保存成功';
|
||||||
|
setTimeout(()=>{closeProfile();},1000);
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAuth(){
|
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');
|
||||||
@@ -627,7 +694,41 @@ document.addEventListener('click',function(){$('exportMenu').classList.remove('s
|
|||||||
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 exportCSV(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const paid=l.paid.length+'/'+l.periods;const status=l.paid.length>=l.periods?'已还清':'还款中';csv+=`${escapeHtml(l.name)},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
|
function exportCSV(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let csv='借款人,借款日期,借款金额,年利率(%),总利息,分期数,还款方式,已还期数,状态\n';loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const paid=l.paid.length+'/'+l.periods;const status=l.paid.length>=l.periods?'已还清':'还款中';csv+=`${escapeHtml(l.name)},${l.date},${l.amount},${l.rate},${ti.toFixed(2)},${l.periods},${methodLabel(l.method)},${paid},${status}\n`;});download('债务统计_'+today()+'.csv',csv,'text/csv;charset=utf-8');}
|
||||||
function exportJSON(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');const data=loans.map(l=>({借款人:l.name,借款日期:l.date,借款金额:l.amount,年利率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',还款计划:l.schedule.map(r=>({期数:r.p,日期:r.date,还款额:r.pay,本金:r.pr,利息:r.it}))}));download('债务统计_'+today()+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
|
function exportJSON(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');const data=loans.map(l=>({借款人:l.name,借款日期:l.date,借款金额:l.amount,年利率:l.rate,分期数:l.periods,还款方式:methodLabel(l.method),已还期数:l.paid.length,状态:l.paid.length>=l.periods?'已还清':'还款中',还款计划:l.schedule.map(r=>({期数:r.p,日期:r.date,还款额:r.pay,本金:r.pr,利息:r.it}))}));download('债务统计_'+today()+'.json',JSON.stringify(data,null,2),'application/json;charset=utf-8');}
|
||||||
function exportHTML(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let h=`<html><head><meta charset="utf-8"><title>债务统计</title><style>body{font-family:"Microsoft YaHei",sans-serif;padding:20px}h2{color:#1d1d1f}table{border-collapse:collapse;width:100%;font-size:13px;margin-bottom:20px}th{background:#0071e3;color:#fff;padding:8px 12px;text-align:left}td{padding:6px 12px;border-bottom:1px solid #d2d2d7}tr:nth-child(even){background:#f5f5f7}.done{color:#34c759;font-weight:600}</style></head><body><h2>债务统计报表 - ${today()}</h2>`;loans.forEach(l=>{const ti=l.schedule.reduce((s,r)=>s+r.it,0);const st=l.paid.length>=l.periods;h+=`<h3>${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></tr>`;l.schedule.forEach(r=>{h+=`<tr><td>${r.p}</td><td>${r.date}</td><td>¥${fmt(r.pay)}</td><td>¥${fmt(r.pr)}</td><td>¥${fmt(r.it)}</td><td>¥${fmt(r.rp)}</td><td>${l.paid.includes(r.p)?'✓ 已还':'待还'}</td></tr>`;});h+=`</table>`;});h+=`</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
|
function exportHTML(e){e.preventDefault();if(!loans.length)return showToast('暂无数据可导出','info');let h=`<html><head><meta charset="utf-8"><title>债务统计</title><style>body{font-family:"Microsoft YaHei",sans-serif;padding:20px}h2{color:#1d1d1f}table{border-collapse:collapse;width:100%;font-size:13px;margin-bottom:20px}th{background:#0071e3;color:#fff;padding:8px 12px;text-align:left}td{padding:6px 12px;border-bottom:1px solid #d2d2d7}tr:nth-child(even){background:#f5f5f7}.done{color:#34c759;font-weight:600}
|
||||||
|
.profile-section{padding:4px 0}
|
||||||
|
.profile-section .form-row{margin-bottom:14px}
|
||||||
|
.profile-section .form-row label{display:block;font-size:13px;font-weight:500;margin-bottom:4px;color:var(--text2)}
|
||||||
|
.profile-section .form-row input{width:100%;padding:10px 14px;border:1px solid #d2d2d7;border-radius:var(--radius-xs);font-size:14px;outline:none;transition:all .2s}
|
||||||
|
.profile-section .form-row input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(0,113,227,.12)}
|
||||||
|
.profile-section .form-row input:disabled{background:#f5f5f7;color:var(--text3)}
|
||||||
|
</style></head><body><h2>债务统计报表 - ${today()}</h2>`;loans.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></tr>`;l.schedule.forEach(r=>{h+=`<tr><td>${r.p}</td><td>${r.date}</td><td>¥${fmt(r.pay)}</td><td>¥${fmt(r.pr)}</td><td>¥${fmt(r.it)}</td><td>¥${fmt(r.rp)}</td><td>${l.paid.includes(r.p)?'✓ 已还':'待还'}</td></tr>`;});h+=`</table>`;});h+=`
|
||||||
|
<div class="modal-mask" id="profileModal">
|
||||||
|
<div class="modal" style="max-width:480px">
|
||||||
|
<h3>个人中心</h3>
|
||||||
|
<div class="profile-section">
|
||||||
|
<div class="form-row"><label>账号</label><input type="text" id="profileAccount" disabled style="background:#f5f5f7"></div>
|
||||||
|
<div class="form-row"><label>昵称</label><input type="text" id="profileNickname" placeholder="请输入昵称"></div>
|
||||||
|
<div class="form-row">
|
||||||
|
<label>邮箱</label>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<input type="email" id="profileEmail" placeholder="请输入邮箱" style="flex:1">
|
||||||
|
<button class="btn btn-ghost" style="flex:none;width:auto;padding:8px 16px;font-size:12px" onclick="sendEmailVerify()">发送验证码</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row" id="emailCodeRow" style="display:none">
|
||||||
|
<label>验证码</label>
|
||||||
|
<input type="text" id="profileEmailCode" placeholder="请输入6位验证码" maxlength="6">
|
||||||
|
</div>
|
||||||
|
<div id="profileError" class="error-msg" style="display:none"></div>
|
||||||
|
<div id="profileSuccess" class="success-msg" style="display:none"></div>
|
||||||
|
<div class="btn-row">
|
||||||
|
<button class="btn btn-ghost" onclick="closeProfile()">取消</button>
|
||||||
|
<button class="btn btn-main" onclick="saveProfile()">保存</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body></html>`;download('债务统计_'+today()+'.html',h,'text/html;charset=utf-8');}
|
||||||
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');}
|
function toggleImport(e){e.stopPropagation();$('importMenu').classList.toggle('show');}
|
||||||
document.addEventListener('click',function(){$('importMenu').classList.remove('show');});
|
document.addEventListener('click',function(){$('importMenu').classList.remove('show');});
|
||||||
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');$('importFile').value='';$('importError').style.display='none';$('importSuccess').style.display='none';$('importModal').classList.add('show');}
|
function showImportModal(e){e.preventDefault();$('importMenu').classList.remove('show');$('importFile').value='';$('importError').style.display='none';$('importSuccess').style.display='none';$('importModal').classList.add('show');}
|
||||||
@@ -699,5 +800,32 @@ 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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user