第44批:玩家端账号安全双层验证(改密/2FA绑定解绑/恢复代码)+admin用户搜索卡住根因修复+sync改密列名修复

This commit is contained in:
ypsdf1
2026-10-22 18:55:26 +08:00
parent d4748c4e62
commit 71b644182c
5 changed files with 1281 additions and 41 deletions
+406 -36
View File
@@ -316,6 +316,7 @@ if ($currentVersion !== $BUILD_VERSION) {
<div class="sidebar-item" data-page="groups" onclick="switchPage('groups')">👥 用户组</div>
<div class="sidebar-item" data-page="ticket" onclick="switchPage('ticket')">📋 工单系统</div>
<div class="sidebar-item" data-page="sn" onclick="switchPage('sn')">🔢 SN设备管理</div>
<div class="sidebar-item" data-page="account" onclick="switchPage('account')">🛡️ 账号安全</div>
</div>
<div class="content" id="content">
@@ -369,6 +370,7 @@ if ($currentVersion !== $BUILD_VERSION) {
</div>
</div>
<script src="qrcode.min.js?v=<?php echo filemtime(__DIR__ . '/qrcode.min.js'); ?>"></script>
<script>
// ★ 人机验证挂件HTML:由 config.php 的 CAPTCHA_PROVIDER 单点决定 CF/VA(全项目只用一家,不混用)。
// 未启用(off / 密钥未配)时为空串,前端不挂件、服务端同步放行,
@@ -2462,7 +2464,7 @@ if ($currentVersion !== $BUILD_VERSION) {
}
}
// ===== 账号 =====
// ===== 账号(2026-10-22 第44批:账号信息 + 账号安全双卡) =====
function renderAccount(el) {
if (IS_PREVIEW || (!AUTHENTICATED && NEED_PASSWORD)) {
el.innerHTML = `<div class="card"><h2>账号信息</h2><div style="text-align:center;padding:40px 20px"><div style="font-size:48px;margin-bottom:16px">🔒</div><p style="color:var(--dim);font-size:14px;margin-bottom:20px">游客模式下无法查看账号信息</p><p style="color:var(--dim);font-size:12px">请先登录游戏获取Web访问令牌</p></div></div>`;
@@ -2476,24 +2478,96 @@ if ($currentVersion !== $BUILD_VERSION) {
<div class="card">
<h2>账号信息</h2>
<div id="accountResult" style="margin-top:16px"></div>
</div>
<div class="card">
<h2 style="display:flex;align-items:center;justify-content:space-between">🛡️ 账号安全
<button class="btn" style="font-size:12px;padding:4px 10px" onclick="acctRefreshStatus()">🔄 刷新</button>
</h2>
<p style="color:var(--dim);font-size:12px;margin:4px 0 12px">修改任何账号信息前需完成两层验证:① 输入一次完整登录密码(强制)→ ② 邮箱验证码 / 认证器动态码 / 恢复代码(三选一)。</p>
<div id="accountSecurity" style="margin-top:4px"><div style="color:var(--dim);font-size:13px;padding:12px 0">加载中...</div></div>
</div>`;
doQueryAccount();
acctRefreshStatus();
}
async function doQueryAccount() {
if (!currentPlayer) return;
const res = await api('register.php', {action: 'query', player: currentPlayer, token: TOKEN});
const div = document.getElementById('accountResult');
if (!div) return;
if (res.success) {
const d = res.data;
const regTime = d.register_time ? new Date(d.register_time * 1000).toLocaleString() : '未知';
const loginTime = d.last_login_time ? new Date(d.last_login_time * 1000).toLocaleString() : '未知';
div.innerHTML = `<table class="table"><tr><td style="color:var(--dim)">用户名</td><td>${d.player_name}</td></tr><tr><td style="color:var(--dim)">注册时间</td><td>${regTime}</td></tr><tr><td style="color:var(--dim)">最后登录</td><td>${loginTime}</td></tr><tr><td style="color:var(--dim)">积分</td><td>${d.points || 0}</td></tr><tr><td style="color:var(--dim)">礼包阶段</td><td>${d.gift_stage || 0}</td></tr><tr><td style="color:var(--dim)">在线时长</td><td>${Math.floor((d.total_online_time||0)/3600)}小时</td></tr></table>`;
} else {
div.innerHTML = `<div class="card" style="border-color:var(--red)">${res.message}</div>`;
div.innerHTML = `<div class="card" style="border-color:var(--red)">${escapeHtml(res.message || '查询失败')}</div>`;
}
}
// ===== 账号安全状态(api/account.php?action=status) =====
let _acctStatus = null;
async function acctRefreshStatus() {
const box = document.getElementById('accountSecurity');
if (!box) return;
box.innerHTML = '<div style="color:var(--dim);font-size:13px;padding:12px 0">加载中...</div>';
const retry = '<button class="btn" style="font-size:12px;padding:2px 8px;margin-left:6px" onclick="acctRefreshStatus()">重试</button>';
try {
const res = await apiPost('account.php', {action: 'status'});
if (!res.success) {
box.innerHTML = `<div style="color:var(--red);font-size:13px;padding:8px 0">${escapeHtml(res.message || '加载失败')}${retry}</div>`;
return;
}
_acctStatus = res.data;
box.innerHTML = acctStatusHtml(res.data);
} catch (e) {
box.innerHTML = `<div style="color:var(--red);font-size:13px;padding:8px 0">连接失败: ${escapeHtml(e.message)}${retry}</div>`;
}
}
function acctRow(label, valueHtml, actionHtml) {
return `<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-bottom:1px solid var(--border)">
<div style="min-width:0"><div style="font-size:13px;color:var(--dim)">${label}</div><div style="font-size:14px;margin-top:2px;word-break:break-all">${valueHtml}</div></div>
<div style="flex-shrink:0;display:flex;gap:8px;align-items:center">${actionHtml || ''}</div>
</div>`;
}
function acctStatusHtml(d) {
let html = '';
if (!d.can_verify) {
html += `<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.35);border-radius:8px;padding:10px 12px;font-size:12.5px;color:var(--red);margin-bottom:10px">⚠️ ${escapeHtml(d.hint || '当前无法完成第二层验证')}</div>`;
}
html += acctRow('登录密码', '已设置(6-20位)',
`<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('change_password','修改登录密码')">修改</button>`);
html += acctRow('绑定邮箱', d.has_email ? escapeHtml(d.email_masked) : '<span style="color:var(--red)">未绑定</span>',
d.has_email ? '<span style="font-size:12px;color:var(--dim)">第二层验证可用</span>'
: '<span style="font-size:12px;color:var(--dim)">请先游戏中 /bindemail</span>');
const twofaVal = d.twofa_bound
? '✅ 已绑定认证器(TOTP)'
: (d.pending_setup ? '⏳ 密钥已生成但未确认' : '<span style="color:var(--dim)">未绑定</span>');
let twofaBtns = '';
if (d.twofa_bound) {
twofaBtns += `<button class="btn btn-dim" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('totp_unbind','解绑二次验证')">解绑</button>`;
} else if (d.can_verify) {
twofaBtns += `<button class="btn btn-primary" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('totp_setup','绑定二次验证器')">绑定</button>`;
}
html += acctRow('二次验证器', twofaVal, twofaBtns);
const recVal = d.has_recovery
? '已生成(未用完)'
: (d.twofa_bound ? '<span style="color:var(--dim)">未生成</span>' : '<span style="color:var(--dim)">绑定认证器后生成</span>');
let recBtns = '';
if (d.has_recovery) {
recBtns += `<button class="btn" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('recovery_list','查看恢复代码')">查看</button>`;
}
if (d.twofa_bound || d.has_recovery) {
recBtns += `<button class="btn btn-yellow" style="font-size:12px;padding:6px 12px" onclick="startAccountVerify('recovery_regenerate','重新生成恢复代码')">${d.has_recovery ? '重新生成' : '生成'}</button>`;
}
html += acctRow('恢复代码', recVal, recBtns);
html += `<p style="color:var(--dim);font-size:12px;margin-top:12px;line-height:1.6">💡 恢复代码是认证器丢失时的备用第二层验证方式(每组一次性使用)。修改密码、绑定/解绑认证器、查看/重生成恢复代码,均需先完成两层验证。</p>`;
return html;
}
// ===== 通用 =====
function toast(msg, type = 'success') {
const t = document.createElement('div');
@@ -2738,51 +2812,347 @@ if ($currentVersion !== $BUILD_VERSION) {
document.getElementById('modalOverlay').style.display = 'flex';
}
// ===== 修改密码 =====
function showChangePasswordModal() {
// ===== 双层验证向导(2026-10-22 第44批 · api/account.php) =====
// 流程:完整密码(第一层) → 邮箱码/动态码/恢复代码(第二层) → ticket(10分钟一次性) → 执行动作
async function apiPost(endpoint, params = {}) {
const url = new URL(API + endpoint, location.href);
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(params)
});
return await res.json();
}
let _acctState = null;
let _lastRecoveryCodes = [];
function startAccountVerify(action, label) {
if (IS_PREVIEW || !TOKEN || !currentPlayer) { showToast('请先登录游戏后再操作', 'error'); return; }
_acctState = { action: action, label: label || action, challenge: null, methods: [], method: null, ticket: null, emailMasked: '' };
acctStepPassword();
}
function acctModalOpen(title) {
closeModal();
document.getElementById('modalTitle').textContent = '修改密码';
document.getElementById('modalBody').innerHTML = `
<p style="color:var(--dim);font-size:13px;margin-bottom:12px">修改您的登录密码</p>
<div class="row"><label>当前密码</label><input type="password" id="changePwdOld" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div class="row"><label>新密码</label><input type="password" id="changePwdNew" placeholder="6-20位" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div class="row"><label>确认新密码</label><input type="password" id="changePwdConfirm" style="width:100%;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div id="changePwdError" style="color:var(--red);font-size:12px;margin-top:4px"></div>`;
document.getElementById('modalConfirm').textContent = '修改密码';
document.getElementById('modalConfirm').onclick = () => doChangePassword();
document.getElementById('modalTitle').textContent = title;
document.getElementById('modalOverlay').style.display = 'flex';
}
async function doChangePassword() {
const oldPwd = document.getElementById('changePwdOld').value.trim();
const newPwd = document.getElementById('changePwdNew').value.trim();
const confirmPwd = document.getElementById('changePwdConfirm').value.trim();
function acctModalSet(html, confirmText, onConfirm) {
document.getElementById('modalBody').innerHTML = html;
const btn = document.getElementById('modalConfirm');
btn.textContent = confirmText;
btn.disabled = false;
btn.onclick = onConfirm;
}
if (!oldPwd || !newPwd || !confirmPwd) { document.getElementById('changePwdError').textContent = '请填写所有字段'; return; }
if (newPwd !== confirmPwd) { document.getElementById('changePwdError').textContent = '两次输入的密码不一致'; return; }
if (newPwd.length < 6 || newPwd.length > 20) { document.getElementById('changePwdError').textContent = '密码长度必须为6-20位'; return; }
function acctErr(msg) {
const e = document.getElementById('acctErr');
if (e) e.textContent = msg || '';
}
document.getElementById('changePwdError').textContent = '处理中...';
// ---- 第一步:完整登录密码(强制层) ----
function acctStepPassword() {
acctModalOpen('🛡️ ' + _acctState.label);
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">第一步(必填):输入一次完整登录密码</div>
<input type="password" id="acctPwd" placeholder="当前登录密码" autocomplete="current-password" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'下一步 →', acctDoStep1);
setTimeout(() => { const p = document.getElementById('acctPwd'); if (p) p.focus(); }, 120);
}
async function acctDoStep1() {
const st = _acctState;
const pwd = document.getElementById('acctPwd') ? document.getElementById('acctPwd').value : '';
if (!pwd) { acctErr('请输入密码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('验证中...');
try {
const url = new URL(API + 'sync.php', location.href);
url.searchParams.set('action', 'change_password');
const res = await fetch(url, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({player: currentPlayer, old_password: oldPwd, new_password: newPwd})
});
const data = await res.json();
if (data.success) {
document.getElementById('changePwdError').innerHTML = '<span style="color:var(--green)">密码修改成功,请重新登录</span>';
setTimeout(() => { closeModal(); location.reload(); }, 2000);
} else {
document.getElementById('changePwdError').textContent = data.message || '修改失败';
}
const res = await apiPost('account.php', {action: 'verify_start', password: pwd});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '密码错误'); return; }
st.challenge = res.data.challenge;
st.methods = res.data.methods || [];
st.emailMasked = res.data.email_masked || '';
if (!st.methods.length) { acctErr('没有可用的第二层验证方式'); return; }
acctStep2(st.methods[0]);
} catch (e) {
document.getElementById('changePwdError').textContent = '连接失败: ' + e.message;
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 第二步:三选一(邮箱码 / 动态码 / 恢复代码) ----
function acctStep2(method) {
const st = _acctState;
const methods = st.methods || [];
if (!method) method = st.method || methods[0];
st.method = method;
const names = { email: '📧 邮箱验证码', totp: '📱 认证器动态码', recovery: '🔑 恢复代码' };
let tabs = '';
if (methods.length > 1) {
tabs = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">';
for (const m of methods) {
const act = m === method;
tabs += `<button class="btn${act ? ' btn-primary' : ''}" style="font-size:12px;padding:6px 10px" onclick="acctStep2('${m}')">${names[m] || m}</button>`;
}
tabs += '</div>';
}
let body = `<div style="font-size:13px;color:var(--dim);margin-bottom:8px">第二步(三选一):${names[method] || method}</div>${tabs}`;
if (method === 'email') {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">验证码将发送至 ${escapeHtml(st.emailMasked || '绑定邮箱')}(5分钟有效)</div>
<div style="display:flex;gap:8px;margin-bottom:10px">
<button class="btn" id="acctSendBtn" onclick="acctSendCode()" style="flex-shrink:0">发送验证码</button>
<input type="text" id="acctEmailCode" maxlength="6" placeholder="6位验证码" inputmode="numeric" autocomplete="off" style="flex:1;min-width:0;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:15px;letter-spacing:4px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
</div>`;
} else if (method === 'totp') {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">打开认证器 App(Google/Microsoft Authenticator 等),输入当前显示的 6 位动态码</div>
<input type="text" id="acctTotpCode" maxlength="6" placeholder="6位动态码" inputmode="numeric" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;letter-spacing:6px;text-align:center" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">`;
} else {
body += `
<div style="font-size:12.5px;color:var(--dim);margin-bottom:8px">输入一组恢复代码(形如 XXXX-XXXX-XXXX-XXXX,横线可省略),每组只能用一次</div>
<input type="text" id="acctRecCode" placeholder="XXXX-XXXX-XXXX-XXXX" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px;letter-spacing:1px;font-family:monospace" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">`;
}
body += '<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>';
acctModalSet(body, '验证并继续 →', acctDoStep2);
}
async function acctSendCode() {
const st = _acctState;
const btn = document.getElementById('acctSendBtn');
if (btn) { btn.disabled = true; btn.textContent = '发送中...'; }
try {
const res = await apiPost('account.php', {action: 'send_email_code', challenge: st.challenge});
if (!res.success) {
if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
acctErr(res.message || '发送失败');
return;
}
acctErr('');
showToast(res.message || '验证码已发送', 'success');
acctCountdown(60);
} catch (e) {
if (btn) { btn.disabled = false; btn.textContent = '发送验证码'; }
acctErr('发送失败: ' + e.message);
}
}
function acctCountdown(sec) {
const btn = document.getElementById('acctSendBtn');
if (sec <= 0) { if (btn) { btn.disabled = false; btn.textContent = '重新发送'; } return; }
if (btn) { btn.disabled = true; btn.textContent = '重新发送(' + sec + 's)'; }
setTimeout(() => acctCountdown(sec - 1), 1000);
}
async function acctDoStep2() {
const st = _acctState;
const m = st.method;
const btn = document.getElementById('modalConfirm');
let code = '';
if (m === 'email') code = (document.getElementById('acctEmailCode') || {}).value || '';
else if (m === 'totp') code = (document.getElementById('acctTotpCode') || {}).value || '';
else code = (document.getElementById('acctRecCode') || {}).value || '';
code = (code || '').trim();
if (!code) { acctErr(m === 'email' ? '请先获取并输入邮箱验证码' : '请输入验证码'); return; }
btn.disabled = true; acctErr('验证中...');
const payload = { action: 'verify_step2', challenge: st.challenge };
if (m === 'email') payload.email_code = code;
else if (m === 'totp') payload.totp_code = code;
else payload.recovery_code = code;
try {
const res = await apiPost('account.php', payload);
btn.disabled = false;
if (!res.success) { acctErr(res.message || '验证失败'); return; }
st.ticket = res.data.ticket;
await acctRunAction();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 两层验证通过后的动作执行器(持 ticket 分发) ----
async function acctRunAction() {
const st = _acctState;
const ticket = st.ticket;
try {
if (st.action === 'change_password') { acctPromptNewPassword(ticket); return; }
if (st.action === 'totp_setup') { await acctFlowTotp(ticket); return; }
if (st.action === 'totp_unbind') {
const hasEmail = _acctStatus && _acctStatus.has_email;
const warn = hasEmail
? '确认解绑二次验证器?\n解绑后恢复代码将全部作废,第二层验证只剩邮箱验证码。'
: '⚠️ 账号未绑定邮箱!\n解绑后将没有任何第二层验证方式,届时将无法再修改密码等账号信息(需先在游戏中 /bindemail 绑定邮箱)。确认仍要解绑?';
if (!(await showConfirm(warn))) { closeModal(); return; }
const res = await apiPost('account.php', {action: 'totp_unbind', ticket: ticket});
if (!res.success) { showToast(res.message || '解绑失败', 'error'); return; }
closeModal();
showToast('已解绑二次验证器', 'success');
acctRefreshStatus();
return;
}
if (st.action === 'recovery_list') {
const res = await apiPost('account.php', {action: 'recovery_list', ticket: ticket});
if (!res.success) { showToast(res.message || '获取失败', 'error'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '以下为当前有效的恢复代码(未用完的):');
return;
}
if (st.action === 'recovery_regenerate') {
if (!(await showConfirm('重新生成恢复代码?\n旧的恢复代码将全部立即作废。'))) { closeModal(); return; }
const res = await apiPost('account.php', {action: 'recovery_regenerate', ticket: ticket});
if (!res.success) { showToast(res.message || '生成失败', 'error'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '新的恢复代码已生成(旧代码全部作废),只显示这一次:');
return;
}
showToast('未知操作: ' + st.action, 'error');
} catch (e) {
showToast('请求失败: ' + e.message, 'error');
}
}
// ---- 修改登录密码(两层验证通过后进入) ----
function acctPromptNewPassword(ticket) {
acctModalOpen('🛡️ 修改登录密码');
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:10px">两层验证已通过,请设置新密码</div>
<div class="row"><label>新密码</label><input type="password" id="acctNewPwd" placeholder="6-20位" autocomplete="new-password" style="width:100%;box-sizing:border-box;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px"></div>
<div class="row"><label>确认新密码</label><input type="password" id="acctNewPwd2" placeholder="再输入一次" autocomplete="new-password" style="width:100%;box-sizing:border-box;padding:8px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()"></div>
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px"></div>`,
'确认修改', () => acctDoChangePassword(ticket));
setTimeout(() => { const p = document.getElementById('acctNewPwd'); if (p) p.focus(); }, 120);
}
async function acctDoChangePassword(ticket) {
const p1 = (document.getElementById('acctNewPwd') || {}).value || '';
const p2 = (document.getElementById('acctNewPwd2') || {}).value || '';
if (!p1 || !p2) { acctErr('请填写两遍新密码'); return; }
if (p1 !== p2) { acctErr('两次输入的密码不一致'); return; }
if (p1.length < 6 || p1.length > 20) { acctErr('密码长度必须为6-20位'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('提交中...');
try {
const res = await apiPost('account.php', {action: 'change_password', ticket: ticket, new_password: p1, confirm_password: p2});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '修改失败'); return; }
closeModal();
showToast('✅ 密码修改成功', 'success');
try { notifyJavaSync(); } catch (e) {}
acctRefreshStatus();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 绑定二次验证器:生成密钥 → 扫码 → 动态码确认 → 一次性返回恢复代码 ----
async function acctFlowTotp(ticket) {
acctModalOpen('🛡️ 绑定二次验证器');
acctModalSet('<div style="color:var(--dim);font-size:13px;padding:12px 0">正在生成密钥...</div>', '关闭', () => closeModal());
let res;
try {
res = await apiPost('account.php', {action: 'totp_setup', ticket: ticket});
} catch (e) {
acctModalSet('<div style="color:var(--red);font-size:13px">连接失败: ' + escapeHtml(e.message) + '</div>', '关闭', () => closeModal());
return;
}
if (!res.success) {
acctModalSet('<div style="color:var(--red);font-size:13px">' + escapeHtml(res.message || '生成失败') + '</div>', '关闭', () => closeModal());
return;
}
const secret = res.data.secret, otpauth = res.data.otpauth;
acctModalSet(`
<div style="font-size:13px;color:var(--dim);margin-bottom:8px">① 用认证器 App 扫描下方二维码(或手动输入密钥)</div>
<div id="acctQr" style="text-align:center;padding:6px 0;min-height:12px"></div>
<div style="display:flex;gap:8px;align-items:center;margin:6px 0 14px">
<input id="acctSecret" readonly value="${escapeHtml(secret)}" style="flex:1;min-width:0;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-family:monospace;font-size:13px" onclick="this.select()">
<button class="btn" style="flex-shrink:0;font-size:12px;padding:6px 10px" onclick="acctCopy(document.getElementById('acctSecret').value)">复制密钥</button>
</div>
<div style="font-size:13px;color:var(--dim);margin-bottom:8px">② 输入认证器显示的 6 位动态码完成确认</div>
<input type="text" id="acctBindCode" maxlength="6" placeholder="6位动态码" inputmode="numeric" autocomplete="off" style="width:100%;box-sizing:border-box;padding:10px 12px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:16px;letter-spacing:6px;text-align:center" onkeydown="if(event.key==='Enter')document.getElementById('modalConfirm').click()">
<div id="acctErr" style="color:var(--red);font-size:12px;min-height:16px;margin-top:8px"></div>`,
'确认绑定 →', () => acctDoBind(ticket));
acctRenderQr(otpauth);
}
function acctRenderQr(text) {
const box = document.getElementById('acctQr');
if (!box) return;
if (typeof QRCode === 'undefined') {
box.innerHTML = '<div style="color:var(--red);font-size:12px;padding:6px">二维码组件未加载,请手动复制下方密钥录入认证器</div>';
return;
}
box.innerHTML = '';
try {
new QRCode(box, {text: text, width: 180, height: 180, colorDark: '#000000', colorLight: '#ffffff', correctLevel: QRCode.CorrectLevel.M});
} catch (e) {
box.innerHTML = '<div style="color:var(--red);font-size:12px">二维码渲染失败,请手动复制下方密钥</div>';
}
}
async function acctDoBind(ticket) {
const code = ((document.getElementById('acctBindCode') || {}).value || '').trim();
if (!/^\d{6}$/.test(code)) { acctErr('请输入6位动态码'); return; }
const btn = document.getElementById('modalConfirm');
btn.disabled = true; acctErr('确认中...');
try {
const res = await apiPost('account.php', {action: 'totp_bind', ticket: ticket, code: code});
btn.disabled = false;
if (!res.success) { acctErr(res.message || '动态码错误'); return; }
acctShowRecoveryCodes(res.data.recovery_codes || [], '✅ 绑定成功!以下恢复代码只显示这一次,请立即保存:');
acctRefreshStatus();
} catch (e) {
btn.disabled = false;
acctErr('连接失败: ' + e.message);
}
}
// ---- 恢复代码展示(明文,一次性查看/生成结果) ----
function acctShowRecoveryCodes(codes, title) {
_lastRecoveryCodes = codes.slice();
acctModalOpen('🔑 恢复代码');
let list = '';
for (const c of codes) {
list += `<div style="font-family:monospace;font-size:14px;letter-spacing:1px;padding:7px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;text-align:center">${escapeHtml(c)}</div>`;
}
acctModalSet(`
<div style="font-size:13px;color:var(--red);margin-bottom:10px">⚠️ ${escapeHtml(title)}</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:12px">${list || '<div style="color:var(--dim);font-size:13px">(无)</div>'}</div>
<button class="btn" style="width:100%;font-size:13px" onclick="acctCopy(_lastRecoveryCodes.join(String.fromCharCode(10)))">📋 复制全部</button>
<div style="color:var(--dim);font-size:12px;margin-top:10px;line-height:1.6">每组恢复代码只能使用一次,用后自动作废。请离线保存,勿截图外传。</div>`,
'我已保存', () => { closeModal(); acctRefreshStatus(); });
}
async function acctCopy(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
} else {
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
showToast('已复制到剪贴板', 'success');
} catch (e) {
showToast('复制失败,请手动选择复制', 'error');
}
}
// ===== 修改密码(旧入口已并入双层验证流程,保留函数名兼容旧调用点) =====
function showChangePasswordModal() {
startAccountVerify('change_password', '修改登录密码');
}
async function doChangePassword() {
showChangePasswordModal();
}
// ===== 重置密码(后端判断有无邮箱) =====
async function doResetPassword() {
const player = document.getElementById('resetPlayer').value.trim();