This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,478 @@
import { useEffect, useState } from 'react';
import type {
AuthAuditLogEntry,
AuthCaptchaChallenge,
AuthRiskBlockSummary,
AuthSessionSummary,
AuthUser,
} from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type AccountModalProps = {
user: AuthUser;
isOpen: boolean;
riskBlocks: AuthRiskBlockSummary[];
sessions: AuthSessionSummary[];
auditLogs: AuthAuditLogEntry[];
loadingRiskBlocks: boolean;
loadingSessions: boolean;
loadingAuditLogs: boolean;
onClose: () => void;
onLogout: () => Promise<void>;
onRefreshRiskBlocks: () => Promise<void>;
onLiftRiskBlock: (scopeType: 'phone' | 'ip') => Promise<void>;
onRefreshSessions: () => Promise<void>;
onLogoutAll: () => Promise<void>;
onRefreshAuditLogs: () => Promise<void>;
onRevokeSession: (sessionId: string) => Promise<void>;
changePhoneCaptchaChallenge: AuthCaptchaChallenge | null;
onSendChangePhoneCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onChangePhone: (phone: string, code: string) => Promise<void>;
};
function resolveLoginMethodLabel(loginMethod: AuthUser['loginMethod']) {
switch (loginMethod) {
case 'wechat':
return '微信登录';
case 'phone':
return '手机号登录';
default:
return '账号登录';
}
}
function formatSessionTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return date.toLocaleString('zh-CN', {
hour12: false,
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function AccountModal({
user,
isOpen,
riskBlocks,
sessions,
auditLogs,
loadingRiskBlocks,
loadingSessions,
loadingAuditLogs,
onClose,
onLogout,
onRefreshRiskBlocks,
onLiftRiskBlock,
onRefreshSessions,
onLogoutAll,
onRefreshAuditLogs,
onRevokeSession,
changePhoneCaptchaChallenge,
onSendChangePhoneCode,
onChangePhone,
}: AccountModalProps) {
const [editingPhone, setEditingPhone] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [changePhoneError, setChangePhoneError] = useState('');
const [changePhoneHint, setChangePhoneHint] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [changingPhone, setChangingPhone] = useState(false);
const [cooldownSeconds, setCooldownSeconds] = useState(0);
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
if (!isOpen) {
return null;
}
return (
<div
className="fixed inset-0 z-[70] flex items-end justify-center bg-black/62 px-4 py-4 sm:items-center"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,_rgba(20,23,31,0.96),_rgba(10,12,18,0.98))] p-5 shadow-[0_24px_80px_rgba(0,0,0,0.58)]"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs uppercase tracking-[0.28em] text-amber-200/70">
</div>
<div className="mt-2 text-2xl font-semibold text-white">
{user.displayName}
</div>
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={onClose}
>
</button>
</div>
<div className="mt-5 grid gap-3 text-sm text-zinc-200">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{resolveLoginMethodLabel(user.loginMethod)}
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{user.phoneNumberMasked || '未绑定'}
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{user.wechatBound ? '已绑定' : '未绑定'}
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
{user.bindingStatus === 'pending_bind_phone'
? ' 待绑定手机号'
: ' 已激活'}
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
void onRefreshRiskBlocks();
}}
>
</button>
</div>
<div className="mt-3 grid gap-3">
{loadingRiskBlocks ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
...
</div>
) : riskBlocks.length > 0 ? (
riskBlocks.map((block) => (
<div
key={`${block.scopeType}:${block.expiresAt}`}
className="rounded-2xl border border-amber-300/20 bg-amber-500/10 px-4 py-3 text-sm text-amber-50"
>
<div className="flex items-center justify-between gap-3">
<span>{block.title}</span>
<span className="text-xs text-amber-100/75">
{Math.max(1, Math.ceil(block.remainingSeconds / 60))}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-amber-100/85">
{block.detail}
</div>
<button
type="button"
className="mt-3 h-9 rounded-2xl border border-emerald-300/20 px-3 text-xs text-emerald-50 transition hover:border-emerald-300/45 hover:bg-emerald-400/10"
onClick={() => {
void onLiftRiskBlock(block.scopeType);
}}
>
</button>
</div>
))
) : (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
</div>
)}
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
void onRefreshSessions();
}}
>
</button>
</div>
<div className="mt-3 grid gap-3">
{loadingSessions ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
...
</div>
) : sessions.length > 0 ? (
sessions.map((session) => (
<div
key={session.sessionId}
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
>
<div className="flex items-center justify-between gap-3">
<span>{session.clientLabel}</span>
<span className="text-xs text-emerald-200/85">
{session.isCurrent ? '当前设备' : '已登录'}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-zinc-400">
{formatSessionTime(session.lastSeenAt)}
</div>
<div className="text-xs leading-5 text-zinc-500">
{formatSessionTime(session.expiresAt)}
</div>
{session.ipMasked ? (
<div className="text-xs leading-5 text-zinc-500">
IP{session.ipMasked}
</div>
) : null}
{!session.isCurrent ? (
<button
type="button"
className="mt-3 h-9 rounded-2xl border border-rose-400/20 px-3 text-xs text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
onClick={() => {
void onRevokeSession(session.sessionId);
}}
>
线
</button>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
</div>
)}
</div>
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
setEditingPhone((current) => !current);
setChangePhoneError('');
setChangePhoneHint('');
}}
>
{editingPhone ? '收起' : '修改'}
</button>
</div>
{editingPhone ? (
<div className="mt-3 grid gap-3 rounded-2xl border border-white/8 bg-white/5 px-4 py-4">
<label className="grid gap-2 text-sm text-zinc-200">
<span></span>
<input
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
value={phone}
inputMode="numeric"
placeholder="13800000000"
onChange={(event) => setPhone(event.target.value)}
/>
</label>
<label className="grid gap-2 text-sm text-zinc-200">
<span></span>
<div className="flex gap-3">
<input
className="h-11 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/40 focus:bg-black/40"
value={code}
inputMode="numeric"
placeholder="输入验证码"
onChange={(event) => setCode(event.target.value)}
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-11 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
setSendingCode(true);
setChangePhoneError('');
try {
const result = await onSendChangePhoneCode(phone, {
challengeId: changePhoneCaptchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setChangePhoneHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch (error) {
setChangePhoneError(
error instanceof Error
? error.message
: '发送验证码失败,请稍后再试。',
);
setChangePhoneHint('');
} finally {
setSendingCode(false);
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{changePhoneHint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{changePhoneHint}
</div>
) : null}
<CaptchaChallengeField
challenge={changePhoneCaptchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{changePhoneError ? (
<div className="rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{changePhoneError}
</div>
) : null}
<button
type="button"
disabled={changingPhone || !phone.trim() || !code.trim()}
className="h-11 rounded-2xl border border-sky-300/20 px-4 text-sm font-medium text-sky-100 transition hover:border-sky-300/45 hover:bg-sky-500/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void (async () => {
setChangingPhone(true);
setChangePhoneError('');
try {
await onChangePhone(phone, code);
setChangePhoneHint('手机号已更新。');
setPhone('');
setCode('');
} catch (error) {
setChangePhoneError(
error instanceof Error
? error.message
: '更换手机号失败,请稍后再试。',
);
} finally {
setChangingPhone(false);
}
})();
}}
>
{changingPhone ? '提交中...' : '确认更换手机号'}
</button>
</div>
) : null}
</div>
<div className="mt-5">
<div className="flex items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.24em] text-zinc-400">
</div>
<button
type="button"
className="rounded-full border border-white/10 px-3 py-1.5 text-[11px] text-zinc-300 transition hover:border-white/20 hover:text-white"
onClick={() => {
void onRefreshAuditLogs();
}}
>
</button>
</div>
<div className="mt-3 grid gap-3">
{loadingAuditLogs ? (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
...
</div>
) : auditLogs.length > 0 ? (
auditLogs.map((log) => (
<div
key={log.id}
className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-200"
>
<div className="flex items-center justify-between gap-3">
<span>{log.title}</span>
<span className="text-xs text-zinc-500">
{formatSessionTime(log.createdAt)}
</span>
</div>
<div className="mt-2 text-xs leading-5 text-zinc-400">
{log.detail}
</div>
{log.ipMasked ? (
<div className="text-xs leading-5 text-zinc-500">
IP{log.ipMasked}
</div>
) : null}
</div>
))
) : (
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-400">
</div>
)}
</div>
</div>
<button
type="button"
className="mt-5 h-11 w-full rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10"
onClick={() => {
void onLogout();
}}
>
退
</button>
<button
type="button"
className="mt-3 h-11 w-full rounded-2xl border border-rose-400/20 px-4 text-sm font-medium text-rose-100 transition hover:border-rose-400/45 hover:bg-rose-500/10"
onClick={() => {
void onLogoutAll();
}}
>
退
</button>
</div>
</div>
);
}

View File

@@ -5,22 +5,69 @@ import {
getStoredAccessToken,
} from '../../services/apiClient';
import {
type AuthAuditLogEntry,
type AuthCaptchaChallenge,
type AuthRiskBlockSummary,
type AuthSessionSummary,
type AuthUser,
bindWechatPhone,
changePhoneNumber,
consumeAuthCallbackResult,
ensureAutoAuthUser,
getAuthAuditLogs,
getAuthRiskBlocks,
getAuthSessions,
getCaptchaChallengeFromError,
getCurrentAuthUser,
liftAuthRiskBlock,
loginWithPhoneCode,
logoutAllAuthSessions,
logoutAuthUser,
revokeAuthSession,
sendPhoneLoginCode,
startWechatLogin,
} from '../../services/authService';
import { AccountModal } from './AccountModal';
import { BindPhoneScreen } from './BindPhoneScreen';
import { LoginScreen } from './LoginScreen';
type AuthGateProps = {
children: ReactNode;
};
type AuthStatus = 'checking' | 'recovering' | 'ready' | 'error';
type AuthStatus =
| 'checking'
| 'recovering'
| 'unauthenticated'
| 'pending_bind_phone'
| 'ready'
| 'error';
const allowDevGuestAutoAuth =
import.meta.env.DEV &&
import.meta.env.VITE_AUTH_ALLOW_DEV_GUEST === 'true';
export function AuthGate({ children }: AuthGateProps) {
const [status, setStatus] = useState<AuthStatus>('checking');
const [user, setUser] = useState<AuthUser | null>(null);
const [error, setError] = useState('');
const [sendingCode, setSendingCode] = useState(false);
const [loggingIn, setLoggingIn] = useState(false);
const [bindingPhone, setBindingPhone] = useState(false);
const [wechatLoading, setWechatLoading] = useState(false);
const [showAccountModal, setShowAccountModal] = useState(false);
const [sessions, setSessions] = useState<AuthSessionSummary[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [auditLogs, setAuditLogs] = useState<AuthAuditLogEntry[]>([]);
const [loadingAuditLogs, setLoadingAuditLogs] = useState(false);
const [riskBlocks, setRiskBlocks] = useState<AuthRiskBlockSummary[]>([]);
const [loadingRiskBlocks, setLoadingRiskBlocks] = useState(false);
const [loginCaptchaChallenge, setLoginCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [bindCaptchaChallenge, setBindCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
const [changePhoneCaptchaChallenge, setChangePhoneCaptchaChallenge] =
useState<AuthCaptchaChallenge | null>(null);
useEffect(() => {
let isActive = true;
@@ -57,31 +104,58 @@ export function AuthGate({ children }: AuthGateProps) {
};
const hydrate = async () => {
const callbackResult = consumeAuthCallbackResult();
if (callbackResult?.error && isActive) {
setError(callbackResult.error);
}
const token = getStoredAccessToken();
if (!token) {
await ensureAutoUser();
if (allowDevGuestAutoAuth) {
await ensureAutoUser();
return;
}
if (!isActive) {
return;
}
setUser(null);
setStatus('unauthenticated');
return;
}
try {
const nextUser = await getCurrentAuthUser();
const nextSession = await getCurrentAuthUser();
if (!isActive) {
return;
}
if (nextUser) {
setUser(nextUser);
setStatus('ready');
setError('');
if (!nextSession.user) {
setUser(null);
setStatus('unauthenticated');
return;
}
await ensureAutoUser();
setUser(nextSession.user);
setStatus(
nextSession.user.bindingStatus === 'pending_bind_phone'
? 'pending_bind_phone'
: 'ready',
);
setError(callbackResult?.error ?? '');
} catch {
if (!isActive) {
return;
}
await ensureAutoUser();
if (allowDevGuestAutoAuth) {
await ensureAutoUser();
return;
}
setUser(null);
setStatus('unauthenticated');
}
};
@@ -100,6 +174,91 @@ export function AuthGate({ children }: AuthGateProps) {
};
}, []);
useEffect(() => {
if (!showAccountModal || status !== 'ready') {
return;
}
let isActive = true;
setLoadingRiskBlocks(true);
setLoadingSessions(true);
setLoadingAuditLogs(true);
void getAuthRiskBlocks()
.then((nextBlocks) => {
if (!isActive) {
return;
}
setRiskBlocks(nextBlocks);
})
.catch((blockError) => {
if (!isActive) {
return;
}
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingRiskBlocks(false);
});
void getAuthSessions()
.then((nextSessions) => {
if (!isActive) {
return;
}
setSessions(nextSessions);
})
.catch((sessionError) => {
if (!isActive) {
return;
}
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingSessions(false);
});
void getAuthAuditLogs()
.then((nextLogs) => {
if (!isActive) {
return;
}
setAuditLogs(nextLogs);
})
.catch((auditError) => {
if (!isActive) {
return;
}
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
})
.finally(() => {
if (!isActive) {
return;
}
setLoadingAuditLogs(false);
});
return () => {
isActive = false;
};
}, [showAccountModal, status]);
if (status === 'checking') {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] text-sm text-zinc-300">
@@ -116,11 +275,135 @@ export function AuthGate({ children }: AuthGateProps) {
);
}
if (status === 'unauthenticated') {
return (
<LoginScreen
sendingCode={sendingCode}
loggingIn={loggingIn}
wechatLoading={wechatLoading}
error={error}
captchaChallenge={loginCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'login', captcha);
setLoginCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setLoginCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setLoggingIn(true);
setError('');
try {
const nextUser = await loginWithPhoneCode(phone, code);
setLoginCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (loginError) {
setError(
loginError instanceof Error
? loginError.message
: '登录失败,请稍后再试。',
);
} finally {
setLoggingIn(false);
}
}}
onStartWechatLogin={async () => {
setWechatLoading(true);
setError('');
try {
await startWechatLogin();
} catch (wechatError) {
setError(
wechatError instanceof Error
? wechatError.message
: '微信登录暂不可用,请稍后再试。',
);
} finally {
setWechatLoading(false);
}
}}
/>
);
}
if (status === 'pending_bind_phone' && user) {
return (
<BindPhoneScreen
user={user}
sendingCode={sendingCode}
binding={bindingPhone}
error={error}
captchaChallenge={bindCaptchaChallenge}
onSendCode={async (phone, captcha) => {
setSendingCode(true);
setError('');
try {
const result = await sendPhoneLoginCode(phone, 'bind_phone', captcha);
setBindCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setBindCaptchaChallenge(captchaChallenge);
}
setError(
sendError instanceof Error
? sendError.message
: '发送验证码失败,请稍后再试。',
);
throw sendError;
} finally {
setSendingCode(false);
}
}}
onSubmit={async (phone, code) => {
setBindingPhone(true);
setError('');
try {
const nextUser = await bindWechatPhone(phone, code);
setBindCaptchaChallenge(null);
setUser(nextUser);
setStatus('ready');
} catch (bindError) {
setError(
bindError instanceof Error
? bindError.message
: '绑定手机号失败,请稍后再试。',
);
} finally {
setBindingPhone(false);
}
}}
onLogout={async () => {
await logoutAuthUser();
setUser(null);
setStatus('unauthenticated');
}}
/>
);
}
if (status !== 'ready' || !user) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#090b11] px-6 text-zinc-200">
<div className="max-w-md rounded-3xl border border-white/10 bg-black/40 px-6 py-7 text-center shadow-[0_20px_60px_rgba(0,0,0,0.35)]">
<div className="text-base font-medium text-zinc-50"></div>
<div className="text-base font-medium text-zinc-50"></div>
<div className="mt-3 text-sm leading-6 text-zinc-300">
{error || '账号恢复失败,请刷新页面后重试。'}
</div>
@@ -142,7 +425,13 @@ export function AuthGate({ children }: AuthGateProps) {
<div className="relative">
<div className="pointer-events-none fixed right-3 top-3 z-50 flex justify-end">
<div className="pointer-events-auto flex items-center gap-2 rounded-full border border-white/10 bg-black/45 px-3 py-2 text-xs text-zinc-200 backdrop-blur">
<span>{user.username}</span>
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-white/25 hover:text-white"
onClick={() => setShowAccountModal(true)}
>
{user.displayName}
</button>
<button
type="button"
className="rounded-full border border-white/10 px-2 py-1 text-[11px] text-zinc-100 transition hover:border-amber-300/40 hover:text-amber-100"
@@ -154,6 +443,118 @@ export function AuthGate({ children }: AuthGateProps) {
</button>
</div>
</div>
<AccountModal
user={user}
isOpen={showAccountModal}
riskBlocks={riskBlocks}
sessions={sessions}
auditLogs={auditLogs}
loadingRiskBlocks={loadingRiskBlocks}
loadingSessions={loadingSessions}
loadingAuditLogs={loadingAuditLogs}
onClose={() => setShowAccountModal(false)}
onLogout={async () => {
await logoutAuthUser();
setShowAccountModal(false);
}}
onRefreshRiskBlocks={async () => {
setLoadingRiskBlocks(true);
try {
setRiskBlocks(await getAuthRiskBlocks());
} catch (blockError) {
setError(
blockError instanceof Error
? blockError.message
: '读取安全状态失败,请稍后再试。',
);
} finally {
setLoadingRiskBlocks(false);
}
}}
onLiftRiskBlock={async (scopeType) => {
try {
await liftAuthRiskBlock(scopeType);
setRiskBlocks(await getAuthRiskBlocks());
setAuditLogs(await getAuthAuditLogs());
} catch (liftError) {
setError(
liftError instanceof Error
? liftError.message
: '解除保护失败,请稍后再试。',
);
}
}}
onRefreshSessions={async () => {
setLoadingSessions(true);
try {
setSessions(await getAuthSessions());
} catch (sessionError) {
setError(
sessionError instanceof Error
? sessionError.message
: '读取登录设备失败,请稍后再试。',
);
} finally {
setLoadingSessions(false);
}
}}
onRefreshAuditLogs={async () => {
setLoadingAuditLogs(true);
try {
setAuditLogs(await getAuthAuditLogs());
} catch (auditError) {
setError(
auditError instanceof Error
? auditError.message
: '读取账号操作记录失败,请稍后再试。',
);
} finally {
setLoadingAuditLogs(false);
}
}}
onRevokeSession={async (sessionId) => {
try {
await revokeAuthSession(sessionId);
setSessions((current) =>
current.filter((session) => session.sessionId !== sessionId),
);
setAuditLogs(await getAuthAuditLogs());
} catch (revokeError) {
setError(
revokeError instanceof Error
? revokeError.message
: '移除登录设备失败,请稍后再试。',
);
}
}}
onLogoutAll={async () => {
await logoutAllAuthSessions();
setShowAccountModal(false);
}}
changePhoneCaptchaChallenge={changePhoneCaptchaChallenge}
onSendChangePhoneCode={async (phone, captcha) => {
try {
const result = await sendPhoneLoginCode(
phone,
'change_phone',
captcha,
);
setChangePhoneCaptchaChallenge(null);
return result;
} catch (sendError) {
const captchaChallenge = getCaptchaChallengeFromError(sendError);
if (captchaChallenge) {
setChangePhoneCaptchaChallenge(captchaChallenge);
}
throw sendError;
}
}}
onChangePhone={async (phone, code) => {
const nextUser = await changePhoneNumber(phone, code);
setChangePhoneCaptchaChallenge(null);
setUser(nextUser);
}}
/>
{children}
</div>
);

View File

@@ -0,0 +1,179 @@
import { useEffect, useState } from 'react';
import type { AuthCaptchaChallenge, AuthUser } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type BindPhoneScreenProps = {
user: AuthUser;
sendingCode: boolean;
binding: boolean;
error: string;
captchaChallenge: AuthCaptchaChallenge | null;
onSendCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onSubmit: (phone: string, code: string) => Promise<void>;
onLogout: () => Promise<void>;
};
export function BindPhoneScreen({
user,
sendingCode,
binding,
error,
captchaChallenge,
onSendCode,
onSubmit,
onLogout,
}: BindPhoneScreenProps) {
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(16,185,129,0.14),_transparent_42%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-emerald-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.05fr_0.95fr]">
<div className="border-b border-emerald-200/10 bg-[linear-gradient(135deg,_rgba(16,185,129,0.14),_rgba(59,130,246,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-emerald-200/70">
</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 rounded-2xl border border-white/8 bg-white/5 px-4 py-4 text-sm text-zinc-300">
{user.displayName}
</div>
</div>
<form
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-emerald-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-emerald-300/25 px-4 text-sm font-medium text-emerald-100 transition hover:border-emerald-300/55 hover:bg-emerald-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
</div>
) : null}
<button
type="submit"
disabled={binding || !phone.trim() || !code.trim()}
className="h-12 rounded-2xl bg-[linear-gradient(135deg,_#10b981,_#22c55e)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{binding ? '正在绑定...' : '绑定手机号并进入游戏'}
</button>
<button
type="button"
className="h-11 rounded-2xl border border-white/10 px-4 text-sm text-zinc-300 transition hover:border-white/25 hover:text-white"
onClick={() => {
void onLogout();
}}
>
</button>
</form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import type { AuthCaptchaChallenge } from '../../services/authService';
type CaptchaChallengeFieldProps = {
challenge: AuthCaptchaChallenge | null;
answer: string;
onAnswerChange: (value: string) => void;
};
export function CaptchaChallengeField({
challenge,
answer,
onAnswerChange,
}: CaptchaChallengeFieldProps) {
if (!challenge) {
return null;
}
return (
<div className="grid gap-3 rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-4">
<div className="text-sm leading-6 text-sky-100">{challenge.promptText}</div>
<img
src={challenge.imageDataUrl}
alt="图形验证码"
className="h-14 w-40 rounded-2xl border border-white/10 bg-black/20 object-cover"
/>
<input
className="h-11 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-sky-300/45 focus:bg-black/40"
value={answer}
placeholder="输入图形验证码"
onChange={(event) => onAnswerChange(event.target.value)}
/>
</div>
);
}

View File

@@ -1,39 +1,82 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import type { AuthCaptchaChallenge } from '../../services/authService';
import { CaptchaChallengeField } from './CaptchaChallengeField';
type LoginScreenProps = {
loading: boolean;
sendingCode: boolean;
loggingIn: boolean;
wechatLoading: boolean;
error: string;
onSubmit: (username: string, password: string) => Promise<void>;
captchaChallenge: AuthCaptchaChallenge | null;
onSendCode: (
phone: string,
captcha?: {
challengeId?: string;
answer?: string;
},
) => Promise<{
cooldownSeconds: number;
expiresInSeconds: number;
}>;
onSubmit: (phone: string, code: string) => Promise<void>;
onStartWechatLogin: () => Promise<void>;
};
export function LoginScreen({
loading,
sendingCode,
loggingIn,
wechatLoading,
error,
captchaChallenge,
onSendCode,
onSubmit,
onStartWechatLogin,
}: LoginScreenProps) {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [captchaAnswer, setCaptchaAnswer] = useState('');
const [cooldownSeconds, setCooldownSeconds] = useState(0);
const [hint, setHint] = useState('');
useEffect(() => {
if (cooldownSeconds <= 0) {
return;
}
const timeoutId = window.setTimeout(() => {
setCooldownSeconds((current) => Math.max(0, current - 1));
}, 1000);
return () => {
window.clearTimeout(timeoutId);
};
}, [cooldownSeconds]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-8 text-zinc-100">
<div className="mx-auto flex min-h-[calc(100vh-4rem)] w-full max-w-5xl items-center justify-center">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.15fr_0.85fr]">
<div className="min-h-screen bg-[radial-gradient(circle_at_top,_rgba(245,158,11,0.18),_transparent_38%),linear-gradient(180deg,_#13151c_0%,_#090b11_100%)] px-4 py-6 text-zinc-100 sm:py-8">
<div className="mx-auto flex min-h-[calc(100vh-3rem)] w-full max-w-5xl items-center justify-center sm:min-h-[calc(100vh-4rem)]">
<div className="grid w-full max-w-4xl overflow-hidden rounded-[28px] border border-amber-200/15 bg-zinc-950/78 shadow-[0_24px_80px_rgba(0,0,0,0.45)] md:grid-cols-[1.08fr_0.92fr]">
<div className="border-b border-amber-200/10 bg-[linear-gradient(135deg,_rgba(245,158,11,0.16),_rgba(20,184,166,0.08))] px-6 py-8 md:border-b-0 md:border-r md:px-10 md:py-12">
<p className="text-xs uppercase tracking-[0.35em] text-amber-200/70">
Genarrative
<div className="selection-hero-brand selection-hero-brand--left">
<div className="selection-hero-brand__title"></div>
<div className="selection-hero-brand__subtitle"> RPG</div>
</div>
<p className="mt-8 text-[11px] font-semibold tracking-[0.32em] text-amber-200/70">
</p>
<h1 className="mt-4 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-zinc-50 md:text-4xl">
</h1>
<p className="mt-4 max-w-md text-sm leading-7 text-zinc-300">
</p>
<div className="mt-8 grid gap-3 text-sm text-zinc-300">
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
3 24 线
</div>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3">
使
</div>
</div>
</div>
@@ -42,32 +85,78 @@ export function LoginScreen({
className="flex flex-col justify-center gap-5 px-6 py-8 md:px-10 md:py-12"
onSubmit={(event) => {
event.preventDefault();
void onSubmit(username, password);
void onSubmit(phone, code);
}}
>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
autoComplete="username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="hero_name"
autoComplete="tel"
inputMode="numeric"
value={phone}
onChange={(event) => setPhone(event.target.value)}
placeholder="13800000000"
/>
</label>
<label className="grid gap-2 text-sm text-zinc-300">
<span></span>
<input
className="h-12 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="至少 6 位"
/>
<span></span>
<div className="flex gap-3">
<input
className="h-12 min-w-0 flex-1 rounded-2xl border border-white/10 bg-black/30 px-4 text-base text-zinc-100 outline-none transition focus:border-amber-300/50 focus:bg-black/40"
inputMode="numeric"
value={code}
onChange={(event) => setCode(event.target.value)}
placeholder="输入验证码"
/>
<button
type="button"
disabled={sendingCode || cooldownSeconds > 0 || !phone.trim()}
className="h-12 shrink-0 rounded-2xl border border-amber-300/25 px-4 text-sm font-medium text-amber-100 transition hover:border-amber-300/55 hover:bg-amber-300/10 disabled:cursor-not-allowed disabled:opacity-55"
onClick={() => {
void (async () => {
try {
const result = await onSendCode(phone, {
challengeId: captchaChallenge?.challengeId,
answer: captchaAnswer,
});
setCooldownSeconds(result.cooldownSeconds);
setHint(
`验证码已发送,有效期约 ${Math.max(1, Math.round(result.expiresInSeconds / 60))} 分钟。`,
);
setCaptchaAnswer('');
} catch {
setHint('');
}
})();
}}
>
{sendingCode
? '发送中...'
: cooldownSeconds > 0
? `${cooldownSeconds}s`
: '获取验证码'}
</button>
</div>
</label>
{hint ? (
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-500/10 px-4 py-3 text-sm text-emerald-100">
{hint}
</div>
) : null}
<CaptchaChallengeField
challenge={captchaChallenge}
answer={captchaAnswer}
onAnswerChange={setCaptchaAnswer}
/>
<div className="rounded-2xl border border-white/8 bg-white/5 px-4 py-3 text-sm text-zinc-300">
</div>
{error ? (
<div className="rounded-2xl border border-rose-400/25 bg-rose-500/10 px-4 py-3 text-sm text-rose-100">
{error}
@@ -76,10 +165,21 @@ export function LoginScreen({
<button
type="submit"
disabled={loading}
disabled={loggingIn || !phone.trim() || !code.trim()}
className="mt-2 h-12 rounded-2xl bg-[linear-gradient(135deg,_#f59e0b,_#f97316)] px-4 text-base font-medium text-zinc-950 transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? '正在进入...' : '进入游戏'}
{loggingIn ? '正在进入...' : '登录并进入游戏'}
</button>
<button
type="button"
disabled={wechatLoading || sendingCode || loggingIn}
className="h-12 rounded-2xl border border-white/12 bg-white/5 px-4 text-base font-medium text-zinc-100 transition hover:border-emerald-300/35 hover:bg-emerald-400/10 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void onStartWechatLogin();
}}
>
{wechatLoading ? '正在跳转微信...' : '微信登录'}
</button>
</form>
</div>