mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
9f15f42b47
Document that NEXT_PUBLIC_WAVELET_BACKEND_URL must stay unset for local next dev so axios hits /api/* and Next rewrites proxy to the backend.
132 lines
3.9 KiB
TypeScript
132 lines
3.9 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
CheckCircle2,
|
|
Loader2,
|
|
ShieldAlert,
|
|
ShieldCheck,
|
|
ShieldQuestion,
|
|
} from 'lucide-react';
|
|
import { getCapToken } from '@/lib/cap-solver';
|
|
import { useTranslations } from 'next-intl';
|
|
|
|
type CapStatus = 'idle' | 'solving' | 'solved' | 'error';
|
|
|
|
interface CapWidgetProps {
|
|
/** Called with the one-time token once the challenge is solved */
|
|
onToken: (token: string) => void;
|
|
/** Called when the challenge fails or encounters an error */
|
|
onError?: (err: Error) => void;
|
|
/** Whether to auto-start solving when the component mounts. Defaults to true. */
|
|
autoStart?: boolean;
|
|
/** PoW scope sent to the backend */
|
|
scope?: string;
|
|
}
|
|
|
|
/**
|
|
* Cap 人机验证小部件
|
|
*
|
|
* autoStart=true(默认):挂载后立即在后台运行 PoW 求解,完成后回调 onToken。
|
|
* autoStart=false:显示「点击开始验证」按钮,用户手动触发后才开始求解。
|
|
*/
|
|
export function CapWidget({
|
|
onToken,
|
|
onError,
|
|
autoStart = true,
|
|
scope = 'login',
|
|
}: CapWidgetProps) {
|
|
const [status, setStatus] = useState<CapStatus>('idle');
|
|
const [errorMsg, setErrorMsg] = useState('');
|
|
const solving = useRef(false);
|
|
const t = useTranslations('auth.capWidget');
|
|
|
|
const solve = async () => {
|
|
if (solving.current) return;
|
|
solving.current = true;
|
|
setStatus('solving');
|
|
setErrorMsg('');
|
|
try {
|
|
const token = await getCapToken(scope);
|
|
setStatus('solved');
|
|
onToken(token);
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
setStatus('error');
|
|
setErrorMsg(error.message);
|
|
onError?.(error);
|
|
} finally {
|
|
solving.current = false;
|
|
}
|
|
};
|
|
|
|
// Auto-start on mount (only when autoStart is enabled)
|
|
useEffect(() => {
|
|
if (autoStart) {
|
|
void solve();
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
return (
|
|
<div className='flex items-center gap-2 rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm'>
|
|
{/* idle + autoStart=false: 手动触发按钮 */}
|
|
{status === 'idle' && !autoStart && (
|
|
<button
|
|
type='button'
|
|
className='flex w-full items-center gap-2 text-left'
|
|
onClick={() => void solve()}
|
|
>
|
|
<ShieldQuestion className='size-4 shrink-0 text-muted-foreground' />
|
|
<span className='flex-1 text-muted-foreground'>
|
|
{t('clickToStart')}
|
|
</span>
|
|
</button>
|
|
)}
|
|
|
|
{/* idle + autoStart=true: 等待自动开始(极短暂状态) */}
|
|
{status === 'idle' && autoStart && (
|
|
<>
|
|
<ShieldAlert className='size-4 shrink-0 text-muted-foreground' />
|
|
<span className='text-muted-foreground'>
|
|
{t('waitingVerification')}
|
|
</span>
|
|
</>
|
|
)}
|
|
|
|
{status === 'solving' && (
|
|
<>
|
|
<Loader2 className='size-4 shrink-0 animate-spin text-primary' />
|
|
<span className='text-muted-foreground'>{t('verifying')}</span>
|
|
</>
|
|
)}
|
|
|
|
{status === 'solved' && (
|
|
<>
|
|
<CheckCircle2 className='size-4 shrink-0 text-green-500' />
|
|
<span className='text-green-600 dark:text-green-400'>
|
|
{t('verificationPassed')}
|
|
</span>
|
|
<ShieldCheck className='ml-auto size-4 shrink-0 text-green-500' />
|
|
</>
|
|
)}
|
|
|
|
{status === 'error' && (
|
|
<button
|
|
type='button'
|
|
className='flex w-full items-center gap-2 text-left'
|
|
onClick={() => void solve()}
|
|
>
|
|
<ShieldAlert className='size-4 shrink-0 text-destructive' />
|
|
<span className='flex-1 text-destructive'>
|
|
{errorMsg || t('verificationFailed')}
|
|
</span>
|
|
<span className='text-xs text-muted-foreground underline'>
|
|
{t('retry')}
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|