import React, { useEffect, useRef, useState } from 'react'; import * as captchaApi from '../api/captcha.js'; import { useDialog } from '../lib/utils.js'; // ── 模块级单例:showCaptcha 挂起请求,由 CaptchaModalHost 消费 ── let pending = null; // { action, type, resolve } let listener = null; function notifyHost() { if (listener) listener(); } /** * 验证码流程入口(迁移自 public/js/captcha.js): * - null 无需验证 / 用户取消 * - object { type: 'proof' | 'recaptcha' | 'turnstile', value } * proof → 内置验证码通过,value 为后端签发的 captcha_proof * recaptcha → reCAPTCHA 验证通过,value 为 siteverify token * turnstile → Turnstile 验证通过,value 为 siteverify token * 调用方提交时按 type 放入 captcha_proof / recaptcha_token / turnstile_token 字段。 */ export function showCaptcha(action) { return new Promise((resolve) => { captchaApi.required(action) .then((r) => { if (!r || !r.required || r.type === 'none') { resolve(null); return; } pending = { action, type: r.type || 'builtin', resolve }; notifyHost(); }) .catch(() => resolve(null)); }); } // 执行并清空排队中的渲染回调(供 onload 全局与竞态兜底共用) function flushCallbacks(type) { const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks'; const q = window[key] || []; window[key] = []; q.forEach((cb) => { try { cb(); } catch { /* 单条失败不影响其余 */ } }); } // 加载第三方验证码脚本(recaptcha / turnstile) // 注意:脚本通过 ?onload=onTurnstileLoad / onRecaptchaLoad 回调通知就绪, // 必须在注入脚本之前定义好全局 onload 函数(事件丢失 → 排队回调永不触发 → 永久卡「正在加载」)。 function ensureThirdPartyScript(type) { if (type === 'recaptcha') { window.recaptchaCallbacks = window.recaptchaCallbacks || []; if (!window.onRecaptchaLoad) { window.onRecaptchaLoad = () => flushCallbacks('recaptcha'); } if (typeof window.grecaptcha === 'undefined' && !document.querySelector('script[src*="recaptcha/api"]')) { const s = document.createElement('script'); s.src = 'https://www.recaptcha.net/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit'; s.async = true; s.defer = true; document.head.appendChild(s); } } else { window.turnstileCallbacks = window.turnstileCallbacks || []; if (!window.onTurnstileLoad) { window.onTurnstileLoad = () => flushCallbacks('turnstile'); } if (typeof window.turnstile === 'undefined' && !document.querySelector('script[src*="turnstile"]')) { const s = document.createElement('script'); s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit'; s.async = true; s.defer = true; document.head.appendChild(s); } } } // 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码 const THIRD_PARTY_LOAD_TIMEOUT = 8000; // 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底 // (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。 // 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级; // 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。 // 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器, // 供弹窗关闭/重开时防泄漏、防残留降级状态。 function queueRender(type, render, onTimeout) { const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile'; if (typeof window[lib] !== 'undefined') { render(); return null; } const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks'; window[key] = window[key] || []; let done = false; // 已渲染 / 已超时降级 / 已清理:任一后不再执行 render let raceTimer = null; let timeoutTimer = null; const wrappedRender = () => { if (done) return; done = true; clearTimeout(raceTimer); clearTimeout(timeoutTimer); render(); }; window[key].push(wrappedRender); ensureThirdPartyScript(type); // 300ms 竞态兜底:脚本恰好在「检查就绪」与「入队」之间完成加载、onload 已错过时重放队列 raceTimer = setTimeout(() => { if (typeof window[lib] !== 'undefined') flushCallbacks(type); }, 300); // 8s 加载超时:库仍未就绪 → 降级;就绪但回调错过 → 冲刷队列兜底 timeoutTimer = setTimeout(() => { if (done) return; if (typeof window[lib] !== 'undefined') { flushCallbacks(type); } else { done = true; clearTimeout(raceTimer); if (onTimeout) onTimeout(); } }, THIRD_PARTY_LOAD_TIMEOUT); return () => { if (done) return; done = true; clearTimeout(raceTimer); clearTimeout(timeoutTimer); const q = window[key]; if (q) { const i = q.indexOf(wrappedRender); if (i >= 0) q.splice(i, 1); } }; } // SHA-256(Proof of Work 使用) async function sha256(str) { const buf = new TextEncoder().encode(str); const hash = await crypto.subtle.digest('SHA-256', buf); return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join(''); } // 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户) function BuiltinCaptcha({ onFinish, notice }) { const [svg, setSvg] = useState(''); const [error, setError] = useState(''); const [answer, setAnswer] = useState(''); const [powDone, setPowDone] = useState(false); const [verifying, setVerifying] = useState(false); const tokenRef = useRef(null); const imgRef = useRef(null); const loadImage = async () => { setError(''); setAnswer(''); const data = await captchaApi.getImage(); if (data && data.token) { tokenRef.current = data.token; setSvg(data.svg); } }; useEffect(() => { loadImage(); // 智能验证(Proof of Work)后台运行,20s 内找到满足难度的 nonce (async () => { try { const chal = await captchaApi.powChallenge(); if (!chal || !chal.token) return; const target = '0'.repeat(chal.difficulty); const start = Date.now(); let nonce = 0; while (Date.now() - start < 20000) { const hash = await sha256(chal.prefix + nonce); if (hash.startsWith(target)) { await captchaApi.powVerify(chal.token, String(nonce)); setPowDone(true); return; } nonce++; } } catch { /* 失败不影响主流程 */ } })(); }, []); // 让后端返回的 SVG 自适应弹窗宽度(照搬 captcha.js 的样式处理) useEffect(() => { if (svg && imgRef.current) { const s = imgRef.current.querySelector('svg'); if (s) s.setAttribute('style', 'width:100%;max-width:240px;height:auto;border-radius:8px;display:block'); } }, [svg]); const submit = async () => { if (!powDone) { setError('智能验证尚未完成,请稍候...'); return; } if (!answer.trim() || !tokenRef.current) { setError('请输入验证码'); return; } setVerifying(true); const v = await captchaApi.verify(tokenRef.current, answer.trim()); setVerifying(false); if (v.success) { onFinish({ type: 'proof', value: v.proof || '' }); } else { setError(v.error || '验证码错误'); tokenRef.current = null; setAnswer(''); loadImage(); } }; return (

验证码

{notice && (

{notice}

)}
{svg ?
:
}
setAnswer(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') submit(); }} placeholder="输入验证码" aria-label="输入验证码" maxLength={6} autoComplete="off" style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }} />
{error &&
{error}
}
{powDone ? ( <> check_circle 智能验证通过 ) : ( <> smart_toy 智能验证中... )}
); } // 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方。 // 脚本 8s 内加载不出来时自动降级到内置 SVG 验证码(proof 路径),不阻塞用户操作。 function ThirdPartyCaptcha({ type, onFinish }) { const siteKey = type === 'recaptcha' ? (window._recaptchaSiteKey || '') : (window._turnstileSiteKey || ''); const [status, setStatus] = useState('正在加载...'); const [degraded, setDegraded] = useState(false); const widgetRef = useRef(null); const doneRef = useRef(false); useEffect(() => { if (!siteKey) { onFinish(null); return; } const container = widgetRef.current; const render = () => { if (doneRef.current) return; // 弹窗已取消/卸载或已降级,不再向已移除的容器渲染 try { if (type === 'recaptcha') { const wid = window.grecaptcha.render(container, { sitekey: siteKey, callback: () => { setStatus('验证通过'); let token = ''; try { token = window.grecaptcha.getResponse(wid); } catch { /* 忽略 */ } setTimeout(() => onFinish({ type: 'recaptcha', value: token || '' }), 300); }, 'expired-callback': () => setStatus('验证已过期'), }); } else { const wid = window.turnstile.render(container, { sitekey: siteKey, callback: () => { setStatus('验证通过'); let token = ''; try { token = window.turnstile.getResponse(wid); } catch { /* 忽略 */ } setTimeout(() => onFinish({ type: 'turnstile', value: token || '' }), 300); }, 'expired-callback': () => setStatus('验证已过期'), }); } setStatus('请完成验证'); } catch { setStatus('加载失败'); setTimeout(() => onFinish(null), 2000); } }; // 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底) // 8s 加载超时 → 自动降级到内置验证码,避免长时间卡「正在加载」阻塞登录 const cleanupQueue = queueRender(type, render, () => { if (doneRef.current) return; doneRef.current = true; setDegraded(true); }); // 卸载/重开时:防止重复回调,清除超时计时器并移除排队回调(防泄漏/防残留降级状态) return () => { doneRef.current = true; if (cleanupQueue) cleanupQueue(); }; }, []); // 降级:第三方脚本长时间加载不出来,复用内置 SVG 验证码完整流程 // (校验仍走 loadImage/verify,调用方拿到 { type: 'proof', value }) if (degraded) { return ( ); } return (

{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}

{status}

); } /** * 全局挂载的验证码弹窗宿主:放入 Layout 内,配合 showCaptcha(action) 使用。 */ export default function CaptchaModalHost() { const [request, setRequest] = useState(null); useEffect(() => { listener = () => setRequest(pending); return () => { listener = null; }; }, []); const finish = (proof) => { if (request) { const resolve = request.resolve; pending = null; setRequest(null); resolve(proof); } }; // 弹窗键盘/焦点管理(B3):Esc 取消、聚焦首个输入、关闭后焦点还给触发元素 const { dialogRef, onKeyDown } = useDialog(!!request, () => finish(null)); if (!request) return null; return (
{ if (e.target === e.currentTarget) finish(null); }} onKeyDown={onKeyDown} > {request.type === 'recaptcha' ? ( ) : request.type === 'turnstile' ? ( ) : ( )}
); }