285 lines
10 KiB
React
285 lines
10 KiB
React
import React, { useEffect, useRef, useState } from 'react';
|
||
import * as captchaApi from '../api/captcha.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));
|
||
});
|
||
}
|
||
|
||
// 加载第三方验证码脚本(recaptcha / turnstile)
|
||
function ensureThirdPartyScript(type) {
|
||
if (type === 'recaptcha') {
|
||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||
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 (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);
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 验证码弹窗
|
||
function BuiltinCaptcha({ onFinish }) {
|
||
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 (
|
||
<div className="dialog" style={{ maxWidth: 380, textAlign: 'center' }}>
|
||
<h3 style={{ marginBottom: 12 }}>验证码</h3>
|
||
<div ref={imgRef} style={{ margin: '0 auto 12px', maxWidth: 280, minHeight: 72 }}>
|
||
{svg
|
||
? <div dangerouslySetInnerHTML={{ __html: svg }} />
|
||
: <div className="spinner" style={{ width: 24, height: 24, margin: '16px auto' }} />}
|
||
</div>
|
||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', justifyContent: 'center' }}>
|
||
<input
|
||
type="text"
|
||
value={answer}
|
||
onChange={(e) => setAnswer(e.target.value)}
|
||
onKeyDown={(e) => { if (e.key === 'Enter') submit(); }}
|
||
placeholder="输入验证码"
|
||
maxLength={6}
|
||
autoComplete="off"
|
||
style={{ flex: 1, textAlign: 'center', fontSize: 20, letterSpacing: 6, textTransform: 'uppercase' }}
|
||
/>
|
||
<button type="button" className="btn btn-icon" title="刷新" onClick={loadImage} style={{ flexShrink: 0 }}>
|
||
<span className="material-icons">refresh</span>
|
||
</button>
|
||
</div>
|
||
{error && <div style={{ color: 'var(--md-ref-error)', fontSize: 13, marginTop: 8 }}>{error}</div>}
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 6, marginTop: 8, fontSize: 13, color: 'var(--md-ref-on-surface-variant)' }}>
|
||
{powDone ? (
|
||
<>
|
||
<span className="material-icons" style={{ fontSize: 16, color: '#4caf50' }}>check_circle</span>
|
||
<span style={{ color: '#4caf50' }}>智能验证通过</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="material-icons" style={{ fontSize: 16 }}>smart_toy</span>
|
||
<span>智能验证中...</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="actions" style={{ justifyContent: 'center', marginTop: 16 }}>
|
||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||
<button type="button" className="btn btn-filled" onClick={submit} disabled={verifying}>确认</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 第三方验证码(reCAPTCHA / Turnstile),验证通过后把 siteverify token 传回调用方
|
||
function ThirdPartyCaptcha({ type, onFinish }) {
|
||
const siteKey = type === 'recaptcha'
|
||
? (window._recaptchaSiteKey || '')
|
||
: (window._turnstileSiteKey || '');
|
||
const [status, setStatus] = useState('正在加载...');
|
||
const widgetRef = useRef(null);
|
||
const doneRef = useRef(false);
|
||
|
||
useEffect(() => {
|
||
if (!siteKey) { onFinish(null); return; }
|
||
const container = widgetRef.current;
|
||
const render = () => {
|
||
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);
|
||
}
|
||
};
|
||
if (type === 'recaptcha') {
|
||
if (typeof window.grecaptcha !== 'undefined') render();
|
||
else {
|
||
window.recaptchaCallbacks = window.recaptchaCallbacks || [];
|
||
window.recaptchaCallbacks.push(render);
|
||
ensureThirdPartyScript(type);
|
||
}
|
||
} else {
|
||
if (typeof window.turnstile !== 'undefined') render();
|
||
else {
|
||
window.turnstileCallbacks = window.turnstileCallbacks || [];
|
||
window.turnstileCallbacks.push(render);
|
||
ensureThirdPartyScript(type);
|
||
}
|
||
}
|
||
// 卸载时不触发重复回调
|
||
return () => { doneRef.current = true; };
|
||
}, []);
|
||
|
||
return (
|
||
<div className="dialog" style={{ maxWidth: 400, textAlign: 'center' }}>
|
||
<h3 style={{ marginBottom: 16 }}>{type === 'recaptcha' ? 'Google reCAPTCHA' : 'Cloudflare Turnstile'}</h3>
|
||
<div ref={widgetRef} style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }} />
|
||
<p className="text-muted" style={{ fontSize: 13 }}>{status}</p>
|
||
<div className="actions" style={{ justifyContent: 'center' }}>
|
||
<button type="button" className="btn btn-text" onClick={() => onFinish(null)}>取消</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 全局挂载的验证码弹窗宿主:放入 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);
|
||
}
|
||
};
|
||
|
||
if (!request) return null;
|
||
|
||
return (
|
||
<div
|
||
className="dialog-overlay active"
|
||
style={{ display: 'flex', zIndex: 9999 }}
|
||
onMouseDown={(e) => { if (e.target === e.currentTarget) finish(null); }}
|
||
>
|
||
{request.type === 'recaptcha' ? (
|
||
<ThirdPartyCaptcha type="recaptcha" onFinish={finish} />
|
||
) : request.type === 'turnstile' ? (
|
||
<ThirdPartyCaptcha type="turnstile" onFinish={finish} />
|
||
) : (
|
||
<BuiltinCaptcha onFinish={finish} />
|
||
)}
|
||
</div>
|
||
);
|
||
}
|