feat: 验证码 8s 加载超时自动降级内置 SVG(captcha_type=both 时后端兼容)
This commit is contained in:
@@ -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-256(Proof 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>
|
||||
|
||||
Reference in New Issue
Block a user