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

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

技术:
- 后端: FastAPI + SQLAlchemy + PostgreSQL
- 前端: 纯 HTML/CSS/JS
- 部署: Docker Compose (新增 build 配置)
2026-06-28 18:06:56 +08:00

521 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>