- 删除 8 个占位页面文件(Account/Vip/Recharge/Billing/Orders/Projects/Quota/ComplianceAssets) - PageDispatcher: PAGE_MAP 支持无组件页面自动渲染 PlaceholderPage - 新增页面只需在 PAGE_MAP 加一行配置,无需创建独立文件 - 删除 services/modules/index.ts barrel,13 个文件改为直接导入 - IDE 跳转到定义一步到位,import 自文档化 - TypeScript 编译零错误 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
266 lines
8.5 KiB
TypeScript
266 lines
8.5 KiB
TypeScript
// ============================================================
|
||
// ForgotPasswordModal — 忘记密码弹窗
|
||
//
|
||
// 场景:未登录用户通过手机号 + 短信验证码重置密码
|
||
// 复用:可在登录页"忘记密码?"入口使用,也可在其他位置独立调用
|
||
//
|
||
// API:
|
||
// - AuthAPI.sendSms({ phone }) 发送短信验证码
|
||
// - AuthAPI.resetPassword({ phone, sms_code, new_password }) 重置密码
|
||
// ============================================================
|
||
|
||
import { useState, useCallback } from 'react';
|
||
import { Modal, Input, Button, Typography, App } from 'antd';
|
||
import {
|
||
PhoneOutlined,
|
||
SafetyCertificateOutlined,
|
||
LockOutlined,
|
||
KeyOutlined,
|
||
} from '@ant-design/icons';
|
||
|
||
import { useTheme } from '@/components/ThemeProvider';
|
||
import { AuthAPI } from '@/services/modules/auth';
|
||
import {
|
||
validatePhone,
|
||
validateSmsCode,
|
||
validatePassword,
|
||
validateConfirmPassword,
|
||
} from '../config/validators';
|
||
import { useSmsCooldown } from '../hooks/use-sms-cooldown';
|
||
|
||
const { Text, Title } = Typography;
|
||
|
||
interface ForgotPasswordModalProps {
|
||
open: boolean;
|
||
onClose: () => void;
|
||
}
|
||
|
||
export function ForgotPasswordModal({ open, onClose }: ForgotPasswordModalProps) {
|
||
const { isDark } = useTheme();
|
||
const { message } = App.useApp();
|
||
|
||
// ---- 表单字段 ----
|
||
const [phone, setPhone] = useState('');
|
||
const [smsCode, setSmsCode] = useState('');
|
||
const [newPassword, setNewPassword] = useState('');
|
||
const [confirmPassword, setConfirmPassword] = useState('');
|
||
|
||
// ---- 错误信息 ----
|
||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||
|
||
// ---- 提交状态 ----
|
||
const [submitting, setSubmitting] = useState(false);
|
||
|
||
// ---- 短信冷却 ----
|
||
const { cooldown, secondsLeft, startCooldown } = useSmsCooldown(60);
|
||
|
||
// ---- 清除单个字段错误 ----
|
||
const clearError = useCallback((name: string) => {
|
||
setErrors((p) => {
|
||
if (!(name in p)) return p;
|
||
const n = { ...p };
|
||
delete n[name];
|
||
return n;
|
||
});
|
||
}, []);
|
||
|
||
// ---- 发送验证码 ----
|
||
const handleSendSms = useCallback(() => {
|
||
const err = validatePhone(phone);
|
||
if (err) {
|
||
setErrors((p) => ({ ...p, phone: err }));
|
||
return;
|
||
}
|
||
clearError('phone');
|
||
startCooldown();
|
||
AuthAPI.sendSms({ phone })
|
||
.then(() => message.success('验证码已发送'))
|
||
.catch((e) => message.error((e as Error).message || '验证码发送失败'));
|
||
}, [phone, startCooldown, clearError, message]);
|
||
|
||
// ---- 提交重置 ----
|
||
const handleSubmit = useCallback(async () => {
|
||
// 逐字段校验
|
||
const newErrors: Record<string, string> = {};
|
||
const phoneErr = validatePhone(phone);
|
||
if (phoneErr) newErrors.phone = phoneErr;
|
||
const smsErr = validateSmsCode(smsCode);
|
||
if (smsErr) newErrors.smsCode = smsErr;
|
||
const pwdErr = validatePassword(newPassword);
|
||
if (pwdErr) newErrors.newPassword = pwdErr;
|
||
const confirmErr = validateConfirmPassword(confirmPassword, newPassword);
|
||
if (confirmErr) newErrors.confirmPassword = confirmErr;
|
||
|
||
if (Object.keys(newErrors).length > 0) {
|
||
setErrors(newErrors);
|
||
return;
|
||
}
|
||
|
||
setErrors({});
|
||
setSubmitting(true);
|
||
try {
|
||
await AuthAPI.resetPassword({
|
||
phone,
|
||
sms_code: smsCode,
|
||
new_password: newPassword,
|
||
});
|
||
message.success('密码重置成功,请使用新密码登录');
|
||
// 重置表单状态
|
||
setPhone('');
|
||
setSmsCode('');
|
||
setNewPassword('');
|
||
setConfirmPassword('');
|
||
onClose();
|
||
} catch (err) {
|
||
message.error((err as Error).message || '密码重置失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
}, [phone, smsCode, newPassword, confirmPassword, onClose, message]);
|
||
|
||
// ---- 关闭时重置 ----
|
||
const handleClose = useCallback(() => {
|
||
setErrors({});
|
||
onClose();
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<Modal
|
||
open={open}
|
||
onCancel={handleClose}
|
||
footer={null}
|
||
width={440}
|
||
centered
|
||
destroyOnHidden={true}
|
||
mask={{ closable: false }}
|
||
styles={{
|
||
body: {
|
||
padding: '24px 32px 16px',
|
||
background: isDark ? '#1A1730' : '#FFFFFF',
|
||
},
|
||
}}
|
||
>
|
||
{/* 标题 */}
|
||
<div className="text-center mb-5">
|
||
<Title level={5} className="!mb-1 gradient-primary-text">
|
||
重置密码
|
||
</Title>
|
||
<Text type="secondary" className="text-xs">
|
||
通过手机短信验证码重置您的密码
|
||
</Text>
|
||
</div>
|
||
|
||
{/* 表单 */}
|
||
<div className="space-y-4">
|
||
{/* 手机号 */}
|
||
<div>
|
||
<Input
|
||
size="large"
|
||
prefix={<PhoneOutlined className="opacity-40" />}
|
||
placeholder="请输入手机号"
|
||
value={phone}
|
||
onChange={(e) => {
|
||
setPhone(e.target.value);
|
||
clearError('phone');
|
||
}}
|
||
status={errors.phone ? 'error' : undefined}
|
||
allowClear
|
||
/>
|
||
{errors.phone && (
|
||
<Text type="danger" className="text-xs ml-1">
|
||
{errors.phone}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* 短信验证码 */}
|
||
<div>
|
||
<div className="flex gap-2">
|
||
<Input
|
||
size="large"
|
||
prefix={<SafetyCertificateOutlined className="opacity-40" />}
|
||
placeholder="请输入验证码"
|
||
value={smsCode}
|
||
onChange={(e) => {
|
||
setSmsCode(e.target.value);
|
||
clearError('smsCode');
|
||
}}
|
||
status={errors.smsCode ? 'error' : undefined}
|
||
className="flex-1"
|
||
allowClear
|
||
/>
|
||
<Button
|
||
size="large"
|
||
type="primary"
|
||
disabled={cooldown}
|
||
onClick={handleSendSms}
|
||
className="text-xs! shrink-0 px-3!"
|
||
>
|
||
{cooldown ? `${secondsLeft}s` : '发送验证码'}
|
||
</Button>
|
||
</div>
|
||
{errors.smsCode && (
|
||
<Text type="danger" className="text-xs ml-1">
|
||
{errors.smsCode}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* 新密码 */}
|
||
<div>
|
||
<Input.Password
|
||
size="large"
|
||
prefix={<LockOutlined className="opacity-40" />}
|
||
placeholder="新密码(6~128位)"
|
||
value={newPassword}
|
||
onChange={(e) => {
|
||
setNewPassword(e.target.value);
|
||
clearError('newPassword');
|
||
}}
|
||
status={errors.newPassword ? 'error' : undefined}
|
||
allowClear
|
||
/>
|
||
{errors.newPassword && (
|
||
<Text type="danger" className="text-xs ml-1">
|
||
{errors.newPassword}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* 确认新密码 */}
|
||
<div>
|
||
<Input.Password
|
||
size="large"
|
||
prefix={<KeyOutlined className="opacity-40" />}
|
||
placeholder="请再次输入新密码"
|
||
value={confirmPassword}
|
||
onChange={(e) => {
|
||
setConfirmPassword(e.target.value);
|
||
clearError('confirmPassword');
|
||
}}
|
||
status={errors.confirmPassword ? 'error' : undefined}
|
||
allowClear
|
||
/>
|
||
{errors.confirmPassword && (
|
||
<Text type="danger" className="text-xs ml-1">
|
||
{errors.confirmPassword}
|
||
</Text>
|
||
)}
|
||
</div>
|
||
|
||
{/* 提交按钮 */}
|
||
<Button
|
||
type="primary"
|
||
block
|
||
size="large"
|
||
loading={submitting}
|
||
onClick={handleSubmit}
|
||
className="h-11! text-base! font-medium! mt-2!"
|
||
>
|
||
重置密码
|
||
</Button>
|
||
</div>
|
||
</Modal>
|
||
);
|
||
}
|