Files
debt-manager/frontend/admin.html
OP 908bab811e fix: 同步两个页面的弹窗结构
- index.html 添加 profileModal HTML
- analysis.html 移除错误位置的 twofaBindModal
- analysis.html 添加 twofaBindModal 到正确位置
- analysis.html profileModal 添加完整 2FA 设置部分
- 两个页面的 header/menu/modal 现在一致

不打包,等用户确认后再打包
2026-07-02 20:53:29 +08:00

542 lines
30 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>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>