- index.html 添加 profileModal HTML - analysis.html 移除错误位置的 twofaBindModal - analysis.html 添加 twofaBindModal 到正确位置 - analysis.html profileModal 添加完整 2FA 设置部分 - 两个页面的 header/menu/modal 现在一致 不打包,等用户确认后再打包
542 lines
30 KiB
HTML
542 lines
30 KiB
HTML
<!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>2FA</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);function escapeHtml(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
|
||
|
||
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>${escapeHtml(g.name)}</strong></td>
|
||
<td>${escapeHtml(g.description||'-')}</td>
|
||
<td>${g.member_count}人</td>
|
||
<td>
|
||
<button class="btn-sm btn-enable" onclick="showMembers(${g.id},'${escapeHtml(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},'${escapeHtml(u.account)}','${u.nickname||''}','${u.real_email||''}','${u.role}')">编辑</button>`;
|
||
actions+=` <button class="btn-sm btn-enable" onclick="sendResetLink(${u.id},'${escapeHtml(u.account)}')">改密</button>`;
|
||
actions+=` <button class="btn-sm btn-danger" onclick="deleteUser(${u.id},'${escapeHtml(u.account)}')">删除</button>`;
|
||
}
|
||
return`<tr><td>${escapeHtml(u.account)}</td><td>${escapeHtml(u.nickname)}</td><td>${emailDisplay}</td><td>${roleBadge}</td><td>${groupBadge}</td><td>${u.debt_count}笔</td><td>${statusBadge}</td>
|
||
<td>${u.role==='admin'?'<span style="color:#94a3b8">-</span>':(u.two_factor_enabled?'<span class="badge badge-active">已启用</span> <button class="btn-sm btn-disable" onclick="toggleUser2FA('+u.id+',false)">禁用</button>':'<span class="badge badge-disabled">未启用</span> <button class="btn-sm btn-enable" onclick="toggleUser2FA('+u.id+',true)">启用</button>')}</td>
|
||
<td>${actions}</td></tr>`;
|
||
}).join('');
|
||
}
|
||
|
||
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} (${escapeHtml(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();}
|
||
|
||
|
||
async function toggleUser2FA(userId, enabled) {
|
||
const action = enabled ? '启用' : '禁用';
|
||
if (!confirm(`确定${action}该用户的双因素认证?`)) return;
|
||
|
||
const res = await apiFetch('/admin/users/' + userId + '/2fa', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({enabled})
|
||
});
|
||
|
||
if (res && res.ok) {
|
||
alert(`用户 2FA 已${action}`);
|
||
loadUsers();
|
||
} else {
|
||
const data = await res.json().catch(() => ({}));
|
||
alert(data.detail || '操作失败');
|
||
}
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
</body>
|
||
</html>
|