Files
debt-manager/frontend/admin.html
OP 498d29dfa9 security: 全面安全加固 v2.1
严重修复:
- C1: JWT密钥生成64字节随机字符串替代硬编码占位符
- C2: 数据库密码改为随机强密码,移除宿主机端口映射
- C3: 管理员默认密码改为强密码

高危修复:
- H1: 移除数据库54326端口映射,仅内部网络访问
- H3: 添加X-Frame-Options/CSP/XSS-Protection等安全头
- H4: 前端所有innerHTML拼接处添加escapeHtml转义防XSS
- H5: SMTP密码在API响应中脱敏显示
- H6: 债务列表接口添加用户隔离,普通用户只能看自己的数据
- H7: 债务详情/计划/记录接口添加归属校验
- H8: 账户级暴力破解锁定(5次失败锁定5分钟)
- H9: 速率限制数据增加过期清理机制防内存泄漏

中危修复:
- M1: 注册/重置密码接口添加后端密码强度校验(最少8位)
- M4: 添加全局异常处理器,500错误不再暴露堆栈
- M5: Docker容器改为非root用户运行
- M6: 添加.dockerignore排除pyc和敏感文件
- M7: 管理员操作写入审计日志
- M8: CORS限制为实际使用的HTTP方法和头

其他:
- Nginx隐藏版本号(server_tokens off)
- 管理员角色修改添加枚举校验
- 密码重置验证码接口返回统一错误信息
2026-06-28 22:46:24 +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);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>${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();}
init();
</script>
</body>
</html>