v2.1: 新增通知提醒功能和安全增强

功能:
- 通知提醒系统: 还款日提醒、逾期提醒、系统消息横幅、登录时通知
- 通知中心UI: 铃铛图标 + 下拉列表 + 未读徽章
- 定时任务: APScheduler 每小时检查即将到期的还款
- 邮件通知: SMTP 发送还款提醒和安全警告
- 异常登录通知: 3/5/10次失败触发网页和邮件通知

安全:
- 登录安全告警: 异常登录次数达到阈值时发送通知
- 密码重置链接: 一次性使用, 5分钟有效期
- 登录频率限制: IP级和账户级限制

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
This commit is contained in:
OP
2026-06-28 18:06:56 +08:00
commit ffca168dbe
41 changed files with 4834 additions and 0 deletions

520
frontend/admin.html Normal file
View File

@@ -0,0 +1,520 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<title>后台管理</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;background:#eef2f7;color:#1a2332;min-height:100vh;font-size:14px}
.header{background:linear-gradient(135deg,#1e3a5f 0%,#2563eb 100%);padding:14px 0;color:#fff}
.header-inner{max-width:1200px;margin:0 auto;padding:0 24px;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:18px;font-weight:600}
.header-right{display:flex;align-items:center;gap:12px}
.header-right a{color:rgba(255,255,255,.8);text-decoration:none;font-size:12px;padding:4px 10px;border:1px solid rgba(255,255,255,.3);border-radius:4px}
.header-right a:hover{background:rgba(255,255,255,.15);color:#fff}
.container{max-width:1200px;margin:0 auto;padding:20px 24px}
.stats-row{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:20px}
.stat-card{background:#fff;border-radius:6px;padding:16px;text-align:center;border:1px solid #e2e8f0}
.stat-card .lbl{font-size:12px;color:#64748b;margin-bottom:4px}
.stat-card .val{font-size:22px;font-weight:700}
.stat-card .val.blue{color:#2563eb}.stat-card .val.green{color:#16a34a}.stat-card .val.orange{color:#ea580c}.stat-card .val.red{color:#dc2626}
.card{background:#fff;border-radius:6px;border:1px solid #d0d7e2;overflow:hidden;margin-bottom:16px}
.card-header{padding:12px 20px;border-bottom:1px solid #e5eaf0;display:flex;justify-content:space-between;align-items:center}
.card-header h3{font-size:15px;color:#1e3a5f}
.card-body{padding:0}
.search-bar{display:flex;gap:8px;align-items:center}
.search-bar input,.search-bar select{padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
.search-bar input:focus{border-color:#2563eb}
table{width:100%;border-collapse:collapse;font-size:13px}
th{background:#f8fafc;padding:10px 16px;text-align:left;font-weight:600;color:#64748b;border-bottom:1px solid #e5eaf0}
td{padding:10px 16px;border-bottom:1px solid #f0f3f7}
tr:hover{background:#f8fafc}
.badge{display:inline-block;padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600}
.badge-pending{background:#fef3c7;color:#b45309}
.badge-active{background:#dcfce7;color:#16a34a}
.badge-disabled{background:#fee2e2;color:#dc2626}
.badge-admin{background:#ede9fe;color:#7c3aed}
.badge-group{background:#dbeafe;color:#1d4ed8}
.btn-sm{padding:4px 10px;border:none;border-radius:4px;font-size:12px;cursor:pointer;font-weight:500}
.btn-approve{background:#dcfce7;color:#16a34a}.btn-approve:hover{background:#bbf7d0}
.btn-enable{background:#dbeafe;color:#2563eb}.btn-enable:hover{background:#bfdbfe}
.btn-disable{background:#fee2e2;color:#dc2626}.btn-disable:hover{background:#fecaca}
.btn-restore{background:#f1f5f9;color:#64748b}.btn-restore:hover{background:#e2e8f0}
.btn-primary{background:#2563eb;color:#fff}.btn-primary:hover{background:#1d4ed8}
.btn-danger{background:#fee2e2;color:#dc2626}.btn-danger:hover{background:#fecaca}
.empty{text-align:center;padding:40px;color:#94a3b8}
.form-inline{display:flex;gap:8px;align-items:center}
.form-inline input{padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
.modal-mask{display:none;position:fixed;inset:0;background:rgba(15,23,42,.4);z-index:100;align-items:center;justify-content:center}
.modal-mask.show{display:flex}
.modal{background:#fff;border-radius:6px;padding:20px;max-width:400px;width:90%}
.modal h3{margin-bottom:12px;font-size:15px;color:#1e3a5f}
.modal .form-row{margin-bottom:10px}
.modal .form-row label{display:block;font-size:12px;color:#64748b;margin-bottom:3px}
.modal .form-row input,.modal .form-row select{width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none}
.modal .btn-row{display:flex;gap:8px;margin-top:14px;justify-content:flex-end}
@media(max-width:768px){.stats-row{grid-template-columns:repeat(2,1fr)}}
/* NOTIFICATION CENTER */
.notif-bell{position:relative;cursor:pointer;padding:6px 10px;border-radius:4px;transition:all .2s}
.notif-bell:hover{background:rgba(255,255,255,.15)}
.notif-bell svg{width:18px;height:18px;fill:rgba(255,255,255,.8);transition:fill .2s}
.notif-bell:hover svg{fill:#fff}
.notif-badge{position:absolute;top:4px;right:4px;min-width:16px;height:16px;background:#dc2626;color:#fff;border-radius:8px;font-size:10px;font-weight:600;display:flex;align-items:center;justify-content:center;padding:0 4px;border:2px solid #2563eb;animation:badgePop .3s ease-out}
@keyframes badgePop{from{transform:scale(0)}to{transform:scale(1)}}
.notif-dropdown{display:none;position:absolute;right:0;top:calc(100% + 8px);background:#fff;border:1px solid #e2e8f0;border-radius:6px;box-shadow:0 12px 48px rgba(0,0,0,.15);width:360px;max-height:480px;overflow:hidden;z-index:60}
.notif-dropdown.show{display:block}
.notif-header{padding:14px 16px;border-bottom:1px solid #e5eaf0;display:flex;justify-content:space-between;align-items:center}
.notif-header h4{font-size:14px;font-weight:600;color:#1e3a5f}
.notif-header .mark-all{font-size:12px;color:#2563eb;cursor:pointer;background:none;border:none;font-family:inherit}
.notif-header .mark-all:hover{text-decoration:underline}
.notif-list{overflow-y:auto;max-height:380px}
.notif-item{padding:12px 16px;border-bottom:1px solid #f0f3f7;cursor:pointer;transition:background .15s;display:flex;gap:12px;align-items:flex-start}
.notif-item:hover{background:#f8fafc}
.notif-item.unread{background:rgba(37,99,235,.03)}
.notif-item .notif-icon{width:32px;height:32px;border-radius:8px;display:flex;align-items:center;justify-content:center;font-size:16px;flex-shrink:0}
.notif-icon.repayment_due{background:rgba(37,99,235,.08)}
.notif-icon.repayment_overdue{background:rgba(220,38,38,.08)}
.notif-icon.system{background:rgba(124,58,237,.08)}
.notif-icon.account{background:rgba(22,163,74,.08)}
.notif-item .notif-content{flex:1;min-width:0}
.notif-item .notif-title{font-size:13px;font-weight:500;color:#1a2332;margin-bottom:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-msg{font-size:12px;color:#64748b;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.notif-item .notif-time{font-size:11px;color:#94a3b8;white-space:nowrap}
.notif-empty{text-align:center;padding:32px;color:#94a3b8;font-size:13px}
</style>
</head>
<body>
<div class="header">
<div class="header-inner">
<h1>🔧 后台管理</h1>
<div class="header-right">
<div style="position:relative">
<div class="notif-bell" onclick="toggleNotifDropdown(event)">
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z"/></svg>
<span class="notif-badge" id="notifBadge" style="display:none">0</span>
</div>
<div class="notif-dropdown" id="notifDropdown">
<div class="notif-header">
<h4>通知</h4>
<button class="mark-all" onclick="markAllNotifRead()">全部已读</button>
</div>
<div class="notif-list" id="notifList">
<div class="notif-empty">暂无通知</div>
</div>
</div>
</div>
<a href="/">返回前台</a>
<a href="#" onclick="adminLogout()">退出</a>
</div>
</div>
</div>
<div class="container">
<div class="stats-row" id="statsRow"></div>
<div class="card">
<div class="card-header">
<h3>用户组管理</h3>
<button class="btn-sm btn-primary" onclick="showCreateGroup()">新增组</button>
</div>
<div class="card-body">
<table>
<thead><tr><th>组名</th><th>描述</th><th>成员数</th><th>操作</th></tr></thead>
<tbody id="groupTable"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>用户管理</h3>
<div class="search-bar">
<button class="btn-sm btn-primary" onclick="showCreateUser()">新增用户</button>
<input type="text" id="searchInput" placeholder="搜索账号/昵称" onkeyup="if(event.key==='Enter')loadUsers()">
<select id="statusFilter" onchange="loadUsers()">
<option value="">全部状态</option>
<option value="pending">待审批</option>
<option value="active">已激活</option>
<option value="disabled">已禁用</option>
</select>
<button class="btn-sm btn-enable" onclick="loadUsers()">查询</button>
</div>
</div>
<div class="card-body">
<table>
<thead><tr><th>账号</th><th>昵称</th><th>邮箱</th><th>角色</th><th></th><th>债务</th><th>状态</th><th>操作</th></tr></thead>
<tbody id="userTable"></tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>站点配置</h3>
<button class="btn-sm btn-primary" onclick="saveSiteUrl()">保存</button>
</div>
<div class="card-body" style="padding:16px 20px">
<div style="display:grid;grid-template-columns:1fr;gap:12px">
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">站点地址(用于邮件中的链接)</label><input type="text" id="siteUrl" placeholder="http://your-domain:806" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
</div>
<div id="siteUrlMsg" style="margin-top:10px;font-size:12px;display:none"></div>
</div>
</div>
<div class="card">
<div class="card-header">
<h3>邮件服务配置</h3>
<div style="display:flex;gap:8px">
<button class="btn-sm btn-enable" onclick="showEmailTestModal()">邮件测试</button>
<button class="btn-sm btn-primary" onclick="saveEmailConfig()">保存配置</button>
</div>
</div>
<div class="card-body" style="padding:16px 20px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px 16px">
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">SMTP 服务器</label><input type="text" id="smtpHost" placeholder="smtp.example.com" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">端口</label><input type="number" id="smtpPort" value="465" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">用户名</label><input type="text" id="smtpUser" placeholder="发件账号" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">密码/授权码</label><input type="password" id="smtpPassword" placeholder="SMTP 密码" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div><label style="font-size:12px;color:#64748b;display:block;margin-bottom:4px">发件人地址</label><input type="text" id="smtpFrom" placeholder="留空则使用用户名" style="width:100%;padding:6px 10px;border:1px solid #c8d1dc;border-radius:4px;font-size:13px;outline:none"></div>
<div style="display:flex;align-items:end"><label style="font-size:13px;cursor:pointer"><input type="checkbox" id="smtpTls" checked style="margin-right:6px">启用 TLS/SSL</label></div>
</div>
<div id="emailConfigMsg" style="margin-top:10px;font-size:12px;display:none"></div>
</div>
</div>
</div>
<div class="modal-mask" id="groupModal">
<div class="modal">
<h3>新增组</h3>
<div class="form-row"><label>组名</label><input type="text" id="groupName" placeholder="输入组名"></div>
<div class="form-row"><label>描述</label><input type="text" id="groupDesc" placeholder="可选"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeGroupModal()">取消</button>
<button class="btn-sm btn-primary" onclick="createGroup()">创建</button>
</div>
</div>
</div>
<div class="modal-mask" id="memberModal">
<div class="modal">
<h3>管理组成员 - <span id="memberGroupName"></span></h3>
<div class="form-row">
<label>添加成员</label>
<select id="memberSelect"></select>
</div>
<div class="btn-row">
<button class="btn-sm btn-primary" onclick="addMember()">添加</button>
</div>
<div style="margin-top:12px">
<label style="font-size:12px;color:#64748b">当前成员:</label>
<div id="memberList" style="margin-top:6px"></div>
</div>
<div class="btn-row" style="margin-top:14px">
<button class="btn-sm btn-restore" onclick="closeMemberModal()">关闭</button>
</div>
</div>
</div>
<div class="modal-mask" id="createUserModal">
<div class="modal">
<h3>新增用户</h3>
<div class="form-row"><label>账号</label><input type="text" id="cuAccount" placeholder="登录账号"></div>
<div class="form-row"><label>密码</label><input type="password" id="cuPassword" placeholder="登录密码"></div>
<div class="form-row"><label>昵称</label><input type="text" id="cuNickname" placeholder="显示名称"></div>
<div class="form-row"><label>邮箱(可选)</label><input type="email" id="cuEmail" placeholder="用于接收通知"></div>
<div class="form-row"><label>角色</label><select id="cuRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
<div id="cuError" style="color:#dc2626;font-size:12px;margin-top:8px;display:none"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeCreateUser()">取消</button>
<button class="btn-sm btn-primary" onclick="doCreateUser()">创建</button>
</div>
</div>
</div>
<div class="modal-mask" id="editUserModal">
<div class="modal">
<h3>编辑用户</h3>
<div class="form-row"><label>账号</label><input type="text" id="euAccount"></div>
<div class="form-row"><label>昵称</label><input type="text" id="euNickname"></div>
<div class="form-row"><label>邮箱</label><input type="email" id="euEmail"></div>
<div class="form-row"><label>角色</label><select id="euRole"><option value="user">用户</option><option value="admin">管理员</option></select></div>
<div id="euError" style="color:#dc2626;font-size:12px;margin-top:8px;display:none"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeEditUser()">取消</button>
<button class="btn-sm btn-primary" onclick="doEditUser()">保存</button>
</div>
</div>
</div>
<div class="modal-mask" id="emailTestModal">
<div class="modal">
<h3>邮件测试</h3>
<div class="form-row"><label>接收邮箱</label><input type="email" id="emailTestTo" placeholder="输入测试接收邮箱"></div>
<div id="emailTestMsg" style="font-size:12px;margin-top:8px;display:none"></div>
<div class="btn-row">
<button class="btn-sm btn-restore" onclick="closeEmailTestModal()">取消</button>
<button class="btn-sm btn-primary" id="emailTestBtn" onclick="doEmailTest()">发送测试</button>
</div>
</div>
</div>
<script>
const API='/api/v1';
let token=localStorage.getItem('access_token')||'';
let refreshToken=localStorage.getItem('refresh_token')||'';
let allGroups=[];
let allUsers=[];
let currentGroupId=null;
const $=id=>document.getElementById(id);
async function apiFetch(path,opts={}){
const headers={'Content-Type':'application/json',...opts.headers};
if(token)headers['Authorization']='Bearer '+token;
let res=await fetch(API+path,{...opts,headers});
if(res.status===401&&refreshToken){
const r=await fetch(API+'/auth/refresh',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({refresh_token:refreshToken})});
if(r.ok){const d=await r.json();token=d.access_token;refreshToken=d.refresh_token;localStorage.setItem('access_token',token);localStorage.setItem('refresh_token',refreshToken);headers['Authorization']='Bearer '+token;res=await fetch(API+path,{...opts,headers});}
else{adminLogout();return null;}
}
return res;
}
async function init(){
if(!token){window.location.href='/';return;}
const res=await apiFetch('/auth/profile');
if(!res||!res.ok){window.location.href='/';return;}
const user=await res.json();
if(user.role!=='admin'){window.location.href='/';return;}
loadStats();loadGroups();loadUsers();loadEmailConfig();loadSiteUrl();
}
async function loadStats(){
const res=await apiFetch('/admin/dashboard');
if(!res||!res.ok)return;
const d=await res.json();
const sd=d.status_distribution||{};
$('statsRow').innerHTML=`
<div class="stat-card"><div class="lbl">总用户</div><div class="val blue">${d.total_users}</div></div>
<div class="stat-card"><div class="lbl">待审批</div><div class="val orange">${sd.pending||0}</div></div>
<div class="stat-card"><div class="lbl">已激活</div><div class="val green">${sd.active||0}</div></div>
<div class="stat-card"><div class="lbl">已禁用</div><div class="val red">${sd.disabled||0}</div></div>`;
}
async function loadGroups(){
const res=await apiFetch('/admin/groups');
if(!res||!res.ok)return;
allGroups=await res.json();
const tb=$('groupTable');
if(!allGroups.length){tb.innerHTML='<tr><td colspan="4" class="empty">暂无组</td></tr>';return;}
tb.innerHTML=allGroups.map(g=>`<tr>
<td><strong>${g.name}</strong></td>
<td>${g.description||'-'}</td>
<td>${g.member_count}人</td>
<td>
<button class="btn-sm btn-enable" onclick="showMembers(${g.id},'${g.name}')">管理成员</button>
<button class="btn-sm btn-danger" onclick="deleteGroup(${g.id})">删除</button>
</td>
</tr>`).join('');
}
async function loadUsers(){
const search=$('searchInput').value;
const status=$('statusFilter').value;
const params=new URLSearchParams();
if(search)params.set('search',search);
if(status)params.set('status_filter',status);
const res=await apiFetch('/admin/users?'+params);
if(!res||!res.ok)return;
allUsers=await res.json();
const tb=$('userTable');
if(!allUsers.length){tb.innerHTML='<tr><td colspan="7" class="empty">暂无用户</td></tr>';return;}
tb.innerHTML=allUsers.map(u=>{
const roleBadge=u.role==='admin'?'<span class="badge badge-admin">管理员</span>':'用户';
const statusBadge=u.status==='pending'?'<span class="badge badge-pending">待审批</span>':u.status==='active'?'<span class="badge badge-active">已激活</span>':'<span class="badge badge-disabled">已禁用</span>';
const groupBadge=u.group_name?`<span class="badge badge-group">${u.group_name}</span>`:'<span style="color:#94a3b8">未分组</span>';
const emailDisplay=u.real_email||'<span style="color:#94a3b8">未绑定</span>';
let actions='';
if(u.role!=='admin'){
if(u.status==='pending')actions=`<button class="btn-sm btn-approve" onclick="approveUser(${u.id})">通过</button>`;
if(u.status==='active')actions=`<button class="btn-sm btn-disable" onclick="setStatus(${u.id},'disabled')">禁用</button>`;
if(u.status==='disabled')actions+=` <button class="btn-sm btn-enable" onclick="setStatus(${u.id},'active')">启用</button> <button class="btn-sm btn-restore" onclick="setStatus(${u.id},'pending')">恢复待审</button>`;
actions+=` <button class="btn-sm btn-enable" onclick="showEditUser(${u.id},'${u.account}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${u.account}')">改密</button>`;
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${u.account}')">删除</button>`;
}
return`<tr><td>${u.account}</td><td>${u.nickname}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td><td>${actions}</td></tr>`;
}).join('');
}
async function approveUser(id){
await apiFetch('/admin/users/'+id+'/status?status=active',{method:'PUT'});
loadStats();loadUsers();
}
async function setStatus(id,status){
await apiFetch('/admin/users/'+id+'/status?status='+status,{method:'PUT'});
loadStats();loadUsers();
}
function showCreateUser(){$('cuAccount').value='';$('cuPassword').value='';$('cuNickname').value='';$('cuEmail').value='';$('cuRole').value='user';$('cuError').style.display='none';$('createUserModal').classList.add('show');}
function closeCreateUser(){$('createUserModal').classList.remove('show');}
async function doCreateUser(){
const account=$('cuAccount').value.trim(),pwd=$('cuPassword').value,nick=$('cuNickname').value.trim(),realEmail=$('cuEmail').value.trim(),role=$('cuRole').value;
if(!account||!pwd){$('cuError').textContent='账号和密码必填';$('cuError').style.display='';return;}
$('cuError').style.display='none';
const res=await apiFetch('/admin/users',{method:'POST',body:JSON.stringify({account,password:pwd,nickname:nick,real_email:realEmail,role})});
if(res&&res.ok){closeCreateUser();loadStats();loadUsers();}
else{const d=await res.json();$('cuError').textContent=d.detail||'创建失败';$('cuError').style.display='';}
}
let editUserId=null;
function showEditUser(id,account,nickname,realEmail,role){editUserId=id;$('euAccount').value=account;$('euNickname').value=nickname;$('euEmail').value=realEmail;$('euRole').value=role;$('euError').style.display='none';$('editUserModal').classList.add('show');}
function closeEditUser(){$('editUserModal').classList.remove('show');editUserId=null;}
async function doEditUser(){
if(!editUserId)return;
const account=$('euAccount').value.trim(),nick=$('euNickname').value.trim(),realEmail=$('euEmail').value.trim(),role=$('euRole').value;
if(!account){$('euError').textContent='账号必填';$('euError').style.display='';return;}
$('euError').style.display='none';
const res=await apiFetch('/admin/users/'+editUserId,{method:'PUT',body:JSON.stringify({account,nickname:nick,real_email:realEmail,role})});
if(res&&res.ok){closeEditUser();loadStats();loadUsers();}
else{const d=await res.json();$('euError').textContent=d.detail||'保存失败';$('euError').style.display='';}
}
async function deleteUser(id,email){
if(!confirm(`确定删除用户 ${email}?此操作不可恢复!`))return;
await apiFetch('/admin/users/'+id,{method:'DELETE'});
loadStats();loadGroups();loadUsers();
}
let pwdUserId=null;
async function sendResetLink(uid,uname){
if(!confirm(`确定向 ${uname} 发送密码重置链接?`))return;
const res=await apiFetch('/admin/users/'+uid+'/password',{method:'PUT'});
if(res&&res.ok){const d=await res.json();alert(d.message);}
else{const d=await res.json().catch(()=>({}));alert(d.detail||'发送失败,请确认用户已绑定邮箱');}
}
function showCreateGroup(){$('groupModal').classList.add('show');$('groupName').value='';$('groupDesc').value='';}
function closeGroupModal(){$('groupModal').classList.remove('show');}
async function createGroup(){
const name=$('groupName').value.trim();if(!name)return;
await apiFetch('/admin/groups',{method:'POST',body:JSON.stringify({name,description:$('groupDesc').value.trim()})});
closeGroupModal();loadGroups();
}
async function deleteGroup(id){
if(!confirm('确定删除该组?组内成员将被移出。'))return;
await apiFetch('/admin/groups/'+id,{method:'DELETE'});
loadGroups();loadUsers();
}
async function showMembers(gid,gname){
currentGroupId=gid;
$('memberGroupName').textContent=gname;
const members=allUsers.filter(u=>u.group_id===gid);
$('memberList').innerHTML=members.length?members.map(u=>`<span style="display:inline-block;padding:3px 8px;margin:2px;background:#eef2f7;border-radius:4px;font-size:12px">${u.nickname||u.account} <a href="#" onclick="removeMember(${u.id});return false" style="color:#dc2626;margin-left:4px">×</a></span>`).join(''):'<span style="font-size:12px;color:#94a3b8">暂无成员</span>';
const unassigned=allUsers.filter(u=>!u.group_id&&u.role!=='admin');
$('memberSelect').innerHTML=unassigned.map(u=>`<option value="${u.id}">${u.nickname||u.account} (${u.account})</option>`).join('');
$('memberModal').classList.add('show');
}
function closeMemberModal(){$('memberModal').classList.remove('show');currentGroupId=null;}
async function addMember(){
const uid=+$('memberSelect').value;if(!uid||!currentGroupId)return;
await apiFetch('/admin/groups/'+currentGroupId+'/members',{method:'POST',body:JSON.stringify({user_id:uid})});
loadGroups();loadUsers();showMembers(currentGroupId,$('memberGroupName').textContent);
}
async function removeMember(uid){
if(!currentGroupId)return;
await apiFetch('/admin/groups/'+currentGroupId+'/members/'+uid,{method:'DELETE'});
loadGroups();loadUsers();showMembers(currentGroupId,$('memberGroupName').textContent);
}
function adminLogout(){
localStorage.removeItem('access_token');localStorage.removeItem('refresh_token');
window.location.href='/';
}
function showEmailTestModal(){$('emailTestTo').value='';$('emailTestMsg').style.display='none';$('emailTestModal').classList.add('show');}
function closeEmailTestModal(){$('emailTestModal').classList.remove('show');}
async function doEmailTest(){
const to=$('emailTestTo').value.trim();
if(!to||!to.includes('@'))return;
const msg=$('emailTestMsg');
const btn=$('emailTestBtn');
btn.disabled=true;btn.textContent='发送中...';msg.style.display='none';
const res=await apiFetch('/admin/email-test',{method:'POST',body:JSON.stringify({to_email:to})});
if(res&&res.ok){
const d=await res.json();
msg.style.display='';msg.style.color='#16a34a';msg.textContent=d.message;
}else{
const d=await res.json().catch(()=>({}));
msg.style.display='';msg.style.color='#dc2626';msg.textContent=d.detail||'发送失败,请检查 SMTP 配置';
}
btn.disabled=false;btn.textContent='发送测试';
}
async function loadEmailConfig(){
const res=await apiFetch('/admin/email-config');
if(!res||!res.ok)return;
const d=await res.json();
$('smtpHost').value=d.smtp_host||'';
$('smtpPort').value=d.smtp_port||465;
$('smtpUser').value=d.smtp_user||'';
$('smtpPassword').value=d.smtp_password||'';
$('smtpFrom').value=d.smtp_from||'';
$('smtpTls').checked=d.smtp_use_tls!==false;
}
async function saveEmailConfig(){
const data={
smtp_host:$('smtpHost').value.trim(),
smtp_port:parseInt($('smtpPort').value)||465,
smtp_user:$('smtpUser').value.trim(),
smtp_password:$('smtpPassword').value,
smtp_from:$('smtpFrom').value.trim(),
smtp_use_tls:$('smtpTls').checked,
};
const res=await apiFetch('/admin/email-config',{method:'PUT',body:JSON.stringify(data)});
const msg=$('emailConfigMsg');
if(res&&res.ok){msg.style.display='';msg.style.color='#16a34a';msg.textContent='配置已保存';}
else{msg.style.display='';msg.style.color='#dc2626';msg.textContent='保存失败';}
setTimeout(()=>{msg.style.display='none';},3000);
}
async function loadSiteUrl(){
const res=await apiFetch('/admin/site-url');
if(!res||!res.ok)return;
const d=await res.json();
$('siteUrl').value=d.site_url||'';
}
async function saveSiteUrl(){
const url=$('siteUrl').value.trim();
const res=await apiFetch('/admin/site-url',{method:'PUT',body:JSON.stringify({site_url:url})});
const msg=$('siteUrlMsg');
if(res&&res.ok){msg.style.display='';msg.style.color='#16a34a';msg.textContent='站点地址已保存';}
else{msg.style.display='';msg.style.color='#dc2626';msg.textContent='保存失败';}
setTimeout(()=>{msg.style.display='none';},3000);
}
let notifPage=1;
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");});
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">${n.title}</div><div class="notif-msg">${n.message}</div></div><div class="notif-time">${timeAgo(n.created_at)}</div></div>`).join("");}
async function readNotif(id){await apiFetch("/notifications/"+id+"/read",{method:"PUT"});loadNotifications();}
async function markAllNotifRead(){await apiFetch("/notifications/read-all",{method:"PUT"});loadNotifications();}
init();
</script>
</body>
</html>