(function (root, factory) { // 账号安全模块同时支持浏览器页面和 Node 单元测试。 if (typeof module === 'object' && module.exports) { module.exports = factory(root); return; } root.SecurityPages = factory(root); if (root.document) { root.document.addEventListener('DOMContentLoaded', function () { root.SecurityPages.init(); }); } })(typeof globalThis !== 'undefined' ? globalThis : window, function (root) { 'use strict'; var documentRef = root.document; var CAPTCHA_OPERATIONS = { phone: 'phone-change', deactivate: 'account-deactivate' }; var SMS_CODE_COOLDOWN_SECONDS = 60; var boundPhone = ''; function getCaptchaOperation(formType) { return CAPTCHA_OPERATIONS[formType] || ''; } function hashPassword(value) { // 接口文档要求密码提交 32 位 MD5,测试环境可传入 hashFn 替代。 if (root.hex_md5) return root.hex_md5(value); if (root.md5) return root.md5(value); return value; } function buildPasswordChangeBody(values, hashFn) { var source = values || {}; var hash = hashFn || hashPassword; return { oldPassword: hash(String(source.oldPassword || '').trim()), newPassword: hash(String(source.newPassword || '').trim()) }; } function buildPhoneChangeBody(values) { var source = values || {}; return { phone: String(source.phone || '').trim(), smsCode: String(source.smsCode || '').trim() }; } function buildDeactivateBody(values) { var source = values || {}; return { smsCode: String(source.smsCode || '').trim() }; } function isDangerConfirmed(value) { return String(value || '').trim() === '确认注销'; } function isPhone(value) { return /^1\d{10}$/.test(String(value || '').trim()); } function isSmsCode(value) { return /^\d{4}$/.test(String(value || '').trim()); } function getApi() { return root.GenealogyApi && root.GenealogyApi.defaultClient; } function shouldRedirectToLogin(api, error) { var status = error && (error.status || error.code); return !api || !api.getToken || !api.getToken() || Number(status) === 401; } function redirectToLogin(api) { if (api && api.clearToken) api.clearToken(); if (!root.location) return; if (typeof root.location.replace === 'function') { root.location.replace('login.html'); return; } root.location.href = 'login.html'; } function redirectUnauthorized(api, error) { if (!shouldRedirectToLogin(api, error)) return false; redirectToLogin(api); return true; } async function ensureCaptcha(form, formType, subject) { // 发送敏感业务短信前,先取得与当前认证动作绑定的验证码票据。 var captcha = root.CaptchaPages; var operationCode = getCaptchaOperation(formType); if (!operationCode) return true; if (!captcha || !captcha.ensureToken) { showMessage('验证码组件未加载,请刷新后重试'); return false; } return Boolean(await captcha.ensureToken(form, { operationCode: operationCode, subject: subject || '' })); } function query(selector, rootNode) { return (rootNode || documentRef).querySelector(selector); } function queryAll(selector, rootNode) { return Array.prototype.slice.call((rootNode || documentRef).querySelectorAll(selector)); } function showMessage(message) { if (root.layui && root.layui.layer) { root.layui.layer.msg(message); return; } root.alert(message); } function getFormValues(form) { var values = {}; queryAll('[name]', form).forEach(function (field) { values[field.name] = field.value; }); return values; } function setStatus(form, type, message) { var status = query('[data-security-status]', form); if (!status) return; if (root.ProfileUI && root.ProfileUI.setApiState && type) { root.ProfileUI.setApiState(status, type, message); return; } status.textContent = message || ''; } function setSubmitting(form, isSubmitting) { queryAll('button[type="submit"]', form).forEach(function (button) { if (!button.dataset.defaultLabel) button.dataset.defaultLabel = button.textContent; button.disabled = isSubmitting; button.textContent = isSubmitting ? '提交中…' : button.dataset.defaultLabel; }); } function setBoundPhoneStatus(type, message) { queryAll('[data-security-bound-phone]').forEach(function (node) { if (root.ProfileUI && root.ProfileUI.setApiState && type) { root.ProfileUI.setApiState(node, type, message); return; } node.textContent = message || ''; }); } function clearCaptchaToken(form) { var field = query('input[name="validToken"]', form); if (field) field.value = ''; } function takeCaptchaToken(form) { var field = query('input[name="validToken"]', form); var token = field && field.value || ''; if (field) field.value = ''; return token; } function startSmsCooldown(button) { var originalText; var remaining = SMS_CODE_COOLDOWN_SECONDS; var interval; if (!button || typeof root.setInterval !== 'function') return; if (button.__smsCooldownTimer && typeof root.clearInterval === 'function') { root.clearInterval(button.__smsCooldownTimer); } originalText = button.getAttribute('data-sms-code-label') || button.textContent.trim(); button.setAttribute('data-sms-code-label', originalText); button.disabled = true; button.textContent = remaining + ' 秒后重试'; interval = root.setInterval(function () { remaining -= 1; if (remaining > 0) { button.textContent = remaining + ' 秒后重试'; return; } if (typeof root.clearInterval === 'function') root.clearInterval(interval); button.__smsCooldownTimer = null; button.disabled = false; button.textContent = originalText; }, 1000); button.__smsCooldownTimer = interval; } function maskPhone(phone) { var value = String(phone || '').trim(); return isPhone(value) ? value.slice(0, 3) + '****' + value.slice(-4) : ''; } async function loadBoundPhone() { var api = getApi(); var profile; var message; if (!api || !documentRef) return; if (redirectUnauthorized(api)) return; setBoundPhoneStatus('loading', '正在读取已绑定手机号…'); try { profile = await api.currentProfile(); boundPhone = String(profile && profile.phone || '').trim(); message = isPhone(boundPhone) ? '验证码将发送至 ' + maskPhone(boundPhone) : '未获取到已绑定手机号'; setBoundPhoneStatus('', message); } catch (error) { if (redirectUnauthorized(api, error)) return; setBoundPhoneStatus('error', '读取已绑定手机号失败,请刷新后重试。'); showMessage(error.message || '账号资料加载失败'); } } async function submitPassword(form) { var api = getApi(); var values = getFormValues(form); if (redirectUnauthorized(api)) return; if (!values.oldPassword || !values.newPassword) { setStatus(form, 'error', '请填写当前密码和新密码'); showMessage('请填写原密码和新密码'); return; } if (String(values.newPassword).length < 6) { setStatus(form, 'error', '新密码至少需要 6 位'); showMessage('新密码至少需要 6 位'); return; } if (values.newPassword !== values.confirmPassword) { setStatus(form, 'error', '两次新密码不一致'); showMessage('两次新密码不一致'); return; } try { setSubmitting(form, true); setStatus(form, 'loading', '正在修改密码…'); await api.changePassword(buildPasswordChangeBody(values)); setStatus(form, '', '密码已修改'); showMessage('密码已修改'); form.reset(); } catch (error) { if (redirectUnauthorized(api, error)) return; setStatus(form, 'error', '修改失败,请稍后重试。'); showMessage(error.message || '密码修改失败'); } finally { setSubmitting(form, false); } } async function submitPhone(form) { var api = getApi(); var body = buildPhoneChangeBody(getFormValues(form)); if (redirectUnauthorized(api)) return; if (!body.phone || !body.smsCode) { setStatus(form, 'error', '请填写新手机号和验证码'); showMessage('请填写新手机号和验证码'); return; } if (!isPhone(body.phone)) { setStatus(form, 'error', '请输入正确的新手机号'); showMessage('请输入正确的新手机号'); return; } if (!isSmsCode(body.smsCode)) { setStatus(form, 'error', '请输入正确的短信验证码'); showMessage('请输入正确的短信验证码'); return; } try { setSubmitting(form, true); setStatus(form, 'loading', '正在换绑手机号…'); await api.changePhone(body); boundPhone = body.phone; setBoundPhoneStatus('', '验证码将发送至 ' + maskPhone(boundPhone)); setStatus(form, '', '手机号已换绑'); showMessage('手机号已换绑'); form.reset(); } catch (error) { if (redirectUnauthorized(api, error)) return; setStatus(form, 'error', '换绑失败,请稍后重试。'); showMessage(error.message || '手机号换绑失败'); } finally { setSubmitting(form, false); } } async function sendPhoneCode(button) { // 换绑手机号使用独立认证动作,避免复用登录或找回密码票据。 var api = getApi(); var form = query('[data-security-form="phone"]'); var values; if (!form || redirectUnauthorized(api)) return; values = getFormValues(form); if (!isPhone(values.phone)) { setStatus(form, 'error', '请输入正确的新手机号'); showMessage('请输入正确的新手机号'); return; } if (!await ensureCaptcha(form, 'phone', values.phone)) return; values = getFormValues(form); values.validToken = takeCaptchaToken(form); button.disabled = true; setStatus(form, 'loading', '正在发送验证码…'); try { await api.sendSmsCode(getCaptchaOperation('phone'), { phone: values.phone, validToken: values.validToken || '' }); setStatus(form, '', '验证码已发送,请查收短信。'); showMessage('验证码已发送'); startSmsCooldown(button); } catch (error) { if (redirectUnauthorized(api, error)) return; setStatus(form, 'error', '验证码发送失败,请稍后重试。'); showMessage(error.message || '验证码发送失败'); button.disabled = false; } } async function sendDeactivateCode(button) { var api = getApi(); var form = query('[data-security-form="deactivate"]'); var values; if (!form || redirectUnauthorized(api)) return; if (!isPhone(boundPhone)) { setStatus(form, 'error', '未获取到已绑定手机号,请刷新后重试。'); showMessage('未获取到已绑定手机号,请刷新后重试'); return; } if (!await ensureCaptcha(form, 'deactivate', boundPhone)) return; values = getFormValues(form); values.validToken = takeCaptchaToken(form); button.disabled = true; setStatus(form, 'loading', '正在发送验证码…'); try { await api.sendSmsCode(getCaptchaOperation('deactivate'), { phone: boundPhone, validToken: values.validToken || '' }); setStatus(form, '', '验证码已发送,请查收短信。'); showMessage('验证码已发送'); startSmsCooldown(button); } catch (error) { if (redirectUnauthorized(api, error)) return; setStatus(form, 'error', '验证码发送失败,请稍后重试。'); showMessage(error.message || '验证码发送失败'); button.disabled = false; } } async function submitDeactivate(form) { var api = getApi(); var values = getFormValues(form); if (redirectUnauthorized(api)) return; if (!isSmsCode(values.smsCode)) { setStatus(form, 'error', '请输入正确的短信验证码'); showMessage('请输入正确的短信验证码'); return; } if (!isDangerConfirmed(values.confirmText)) { setStatus(form, 'error', '请完整输入“确认注销”'); showMessage('请完整输入“确认注销”'); return; } if (root.confirm && !root.confirm('账号注销后将退出登录,确认继续?')) return; try { setSubmitting(form, true); setStatus(form, 'loading', '正在提交注销申请…'); await api.deactivateAccount(buildDeactivateBody(values)); showMessage('账号已提交注销'); root.location.href = 'index.html'; } catch (error) { if (redirectUnauthorized(api, error)) return; setStatus(form, 'error', '注销失败,请稍后重试。'); showMessage(error.message || '账号注销失败'); } finally { setSubmitting(form, false); } } async function logout() { var api = getApi(); if (root.confirm && !root.confirm('确认退出当前账号?')) return; try { if (api) await api.logout(); } catch (error) { // 退出请求失败时客户端仍会清理本地令牌,页面必须离开受保护区域。 } finally { root.location.href = 'index.html'; } } function bindActions() { if (!documentRef) return; documentRef.addEventListener('submit', function (event) { var form = event.target.closest('[data-security-form]'); var formType; if (!form) return; event.preventDefault(); if (!form.checkValidity()) { setStatus(form, 'error', '请检查表单填写是否正确'); form.reportValidity(); return; } formType = form.getAttribute('data-security-form'); if (formType === 'password') submitPassword(form); if (formType === 'phone') submitPhone(form); if (formType === 'deactivate') submitDeactivate(form); }); documentRef.addEventListener('click', function (event) { var codeButton = event.target.closest('[data-security-send-code]'); var deactivateCodeButton = event.target.closest('[data-security-send-deactivate-code]'); var button = event.target.closest('[data-security-logout]'); if (codeButton) { event.preventDefault(); sendPhoneCode(codeButton); return; } if (deactivateCodeButton) { event.preventDefault(); sendDeactivateCode(deactivateCodeButton); return; } if (!button) return; event.preventDefault(); logout(); }); } function bindCaptchaTokenInvalidation() { queryAll('[data-security-form] input[name="phone"]').forEach(function (field) { field.addEventListener('input', function () { clearCaptchaToken(field.closest('[data-security-form]')); }); }); } function init() { if (!documentRef || !query('[data-security-page]')) return; if (redirectUnauthorized(getApi())) return; bindActions(); bindCaptchaTokenInvalidation(); loadBoundPhone(); } return { buildPasswordChangeBody: buildPasswordChangeBody, buildPhoneChangeBody: buildPhoneChangeBody, buildDeactivateBody: buildDeactivateBody, getCaptchaOperation: getCaptchaOperation, isDangerConfirmed: isDangerConfirmed, isPhone: isPhone, isSmsCode: isSmsCode, takeCaptchaToken: takeCaptchaToken, shouldRedirectToLogin: shouldRedirectToLogin, init: init }; });