feat: 验证码 8s 加载超时自动降级内置 SVG(captcha_type=both 时后端兼容)

This commit is contained in:
2026-08-12 17:04:19 +08:00
parent bc007c165f
commit bfbcf69e22
+95 -11
View File
@@ -68,18 +68,64 @@ function ensureThirdPartyScript(type) {
}
}
// 第三方验证码加载超时(毫秒):脚本注入后若库仍未就绪,自动降级到内置验证码
const THIRD_PARTY_LOAD_TIMEOUT = 8000;
// 注册渲染回调:库就绪立即执行,未就绪排队等 onload 冲刷;加 300ms 竞态兜底
// (脚本恰好在「检查就绪」与「入队」之间加载完成、onload 已错过时重放队列)。
function queueRender(type, render) {
// 脚本注入后启动 8s 加载超时:届时库仍未就绪 → 调用 onTimeout 触发降级;
// 库已就绪但回调错过 → 冲刷队列兜底(wrappedRender 执行并自行清理计时器)。
// 返回清理函数:移除排队回调并清除 300ms 竞态与 8s 超时两个计时器,
// 供弹窗关闭/重开时防泄漏、防残留降级状态。
function queueRender(type, render, onTimeout) {
const lib = type === 'recaptcha' ? 'grecaptcha' : 'turnstile';
if (typeof window[lib] !== 'undefined') { render(); return; }
if (typeof window[lib] !== 'undefined') { render(); return null; }
const key = type === 'recaptcha' ? 'recaptchaCallbacks' : 'turnstileCallbacks';
window[key] = window[key] || [];
window[key].push(render);
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);
setTimeout(() => {
// 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-256Proof of Work 使用)
@@ -89,8 +135,8 @@ async function sha256(str) {
return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, '0')).join('');
}
// 内置 SVG 验证码弹窗
function BuiltinCaptcha({ onFinish }) {
// 内置 SVG 验证码弹窗(notice 为可选提示,用于第三方降级时告知用户)
function BuiltinCaptcha({ onFinish, notice }) {
const [svg, setSvg] = useState('');
const [error, setError] = useState('');
const [answer, setAnswer] = useState('');
@@ -159,6 +205,23 @@ function BuiltinCaptcha({ onFinish }) {
return (
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
<h3 style={{ marginBottom: 12 }}>验证码</h3>
{notice && (
<p
role="status"
aria-live="polite"
style={{
color: 'var(--md-ref-on-surface-variant)',
fontSize: 13,
lineHeight: 1.5,
margin: '0 0 12px',
padding: '8px 12px',
background: 'var(--md-ref-surface-container-high)',
borderRadius: 8,
}}
>
{notice}
</p>
)}
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
{svg
? <div dangerouslySetInnerHTML={{ __html: svg }} />
@@ -202,12 +265,14 @@ function BuiltinCaptcha({ onFinish }) {
);
}
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
// 第三方验证码(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);
@@ -215,7 +280,7 @@ function ThirdPartyCaptcha({ type, onFinish }) {
if (!siteKey) { onFinish(null); return; }
const container = widgetRef.current;
const render = () => {
if (doneRef.current) return; // 弹窗已取消/卸载,不再向已移除的容器渲染
if (doneRef.current) return; // 弹窗已取消/卸载或已降级,不再向已移除的容器渲染
try {
if (type === 'recaptcha') {
const wid = window.grecaptcha.render(container, {
@@ -247,11 +312,30 @@ function ThirdPartyCaptcha({ type, onFinish }) {
}
};
// 就绪立即渲染;未就绪排队等 onload 冲刷(含 300ms 竞态兜底)
queueRender(type, render);
// 卸载时不触发重复回调
return () => { doneRef.current = true; };
// 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 (
<BuiltinCaptcha
onFinish={onFinish}
notice="第三方验证码加载失败,已切换为内置验证码"
/>
);
}
return (
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>