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>
);
}