BindEmailModal.tsx 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. 'use client';
  2. import { useState, useEffect } from 'react';
  3. import api from '@/lib/api';
  4. import { X, Mail, Loader2, Save, Lock, ArrowRight, ArrowLeft } from 'lucide-react';
  5. import { useLanguage } from '@/lib/i18n/LanguageContext';
  6. // 1. 引入通用消息弹窗
  7. import MessageModal from '@/components/common/MessageModal';
  8. interface BindEmailModalProps {
  9. isOpen: boolean;
  10. onClose: () => void;
  11. onSuccess: () => void;
  12. }
  13. export default function BindEmailModal({ isOpen, onClose, onSuccess }: BindEmailModalProps) {
  14. const { t } = useLanguage();
  15. const [step, setStep] = useState<1 | 2>(1);
  16. const [loading, setLoading] = useState(false);
  17. const [email, setEmail] = useState('');
  18. const [code, setCode] = useState('');
  19. const [countdown, setCountdown] = useState(0);
  20. // 2. 消息弹窗状态
  21. const [msgModal, setMsgModal] = useState({
  22. isOpen: false,
  23. title: '',
  24. message: '',
  25. type: 'info' as 'info' | 'error' | 'success',
  26. onOk: null as (() => void) | null,
  27. });
  28. const showMessage = (msg: string, type: 'info' | 'error' | 'success' = 'info', onOk?: () => void) => {
  29. setMsgModal({
  30. isOpen: true,
  31. title: type === 'error' ? t('common.error') : t('common.notice'),
  32. message: msg,
  33. type,
  34. onOk: onOk || null,
  35. });
  36. };
  37. const handleCloseMsg = () => {
  38. const callback = msgModal.onOk;
  39. setMsgModal(prev => ({ ...prev, isOpen: false }));
  40. if (callback) callback();
  41. };
  42. useEffect(() => {
  43. let timer: NodeJS.Timeout;
  44. if (countdown > 0) {
  45. timer = setTimeout(() => setCountdown(c => c - 1), 1000);
  46. }
  47. return () => clearTimeout(timer);
  48. }, [countdown]);
  49. useEffect(() => {
  50. if (isOpen) {
  51. setStep(1);
  52. setCode('');
  53. }
  54. }, [isOpen]);
  55. const handleSendCode = async (e?: React.FormEvent) => {
  56. if (e) e.preventDefault();
  57. if (!email) return showMessage(t('bind_email.alert_input_email'), 'error');
  58. if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return showMessage(t('bind_email.alert_invalid_email'), 'error');
  59. setLoading(true);
  60. try {
  61. await api.post('/api/auth/send-bind-code', { email });
  62. showMessage(`${t('bind_email.alert_code_sent')} ${email}`, 'success');
  63. setStep(2);
  64. setCountdown(60);
  65. } catch (error: any) {
  66. console.error(error);
  67. const msg = error.response?.data?.message || t('common.unknown_error');
  68. showMessage(`${t('bind_email.alert_send_failed')}: ${msg}`, 'error');
  69. } finally {
  70. setLoading(false);
  71. }
  72. };
  73. const handleVerifyAndBind = async (e: React.FormEvent) => {
  74. e.preventDefault();
  75. if (!code) return showMessage(t('bind_email.alert_input_code'), 'error');
  76. if (code.length !== 6) return showMessage(t('bind_email.alert_code_length'), 'error');
  77. setLoading(true);
  78. try {
  79. const res = await api.post('/api/auth/bind-email', { email, code });
  80. const data = res.data.data || res.data;
  81. const newToken = data.token || data.access_token;
  82. const newUser = data.user;
  83. if (newToken) {
  84. localStorage.setItem('rsid', newToken);
  85. if (newUser) {
  86. localStorage.setItem('user_info', JSON.stringify(newUser));
  87. } else {
  88. const oldUser = JSON.parse(localStorage.getItem('user_info') || '{}');
  89. localStorage.setItem('user_info', JSON.stringify({ ...oldUser, email }));
  90. }
  91. window.dispatchEvent(new Event('storage'));
  92. // 绑定成功后提示,点击确认后关闭弹窗并执行 onSuccess
  93. showMessage(t('bind_email.success'), 'success', () => {
  94. onSuccess();
  95. onClose();
  96. });
  97. } else {
  98. throw new Error("Token missing");
  99. }
  100. } catch (error: any) {
  101. console.error(error);
  102. const msg = error.response?.data?.message || t('common.unknown_error');
  103. showMessage(`${t('bind_email.failed')}: ${msg}`, 'error');
  104. } finally {
  105. setLoading(false);
  106. }
  107. };
  108. if (!isOpen) return null;
  109. return (
  110. <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4 animate-in fade-in duration-200">
  111. <div className="bg-white rounded-xl shadow-2xl w-full max-w-md overflow-hidden animate-in zoom-in-95 duration-200">
  112. {/* Header */}
  113. <div className="px-6 py-4 border-b flex justify-between items-center bg-slate-50">
  114. <h3 className="font-bold text-gray-900 text-lg flex items-center gap-2">
  115. {step === 1 ? <Mail size={20} className="text-blue-600"/> : <Lock size={20} className="text-blue-600"/>}
  116. {step === 1 ? t('bind_email.title_step1') : t('bind_email.title_step2')}
  117. </h3>
  118. <button onClick={onClose} className="text-gray-400 hover:text-gray-600 transition p-1 hover:bg-gray-200 rounded-full">
  119. <X size={24} />
  120. </button>
  121. </div>
  122. <div className="p-6">
  123. {/* Progress Indicator */}
  124. <div className="flex gap-2 mb-6">
  125. <div className={`h-1.5 flex-1 rounded-full transition-colors duration-300 ${step >= 1 ? 'bg-blue-600' : 'bg-gray-200'}`}></div>
  126. <div className={`h-1.5 flex-1 rounded-full transition-colors duration-300 ${step >= 2 ? 'bg-blue-600' : 'bg-gray-200'}`}></div>
  127. </div>
  128. {step === 1 ? (
  129. <form onSubmit={handleSendCode} className="space-y-5">
  130. <p className="text-sm text-gray-500 leading-relaxed">
  131. {t('bind_email.desc_step1')}
  132. </p>
  133. <div>
  134. <label className="block text-xs font-bold text-slate-500 uppercase mb-1">{t('bind_email.email_label')}</label>
  135. <input
  136. type="email" required
  137. className="w-full border border-slate-300 rounded-lg p-3 text-sm focus:ring-2 focus:ring-blue-500 outline-none transition"
  138. placeholder={t('bind_email.email_placeholder')}
  139. value={email}
  140. onChange={(e) => setEmail(e.target.value)}
  141. />
  142. </div>
  143. <button
  144. type="submit"
  145. disabled={loading}
  146. className="w-full bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 transition flex justify-center items-center gap-2 disabled:opacity-50 shadow-md shadow-blue-100"
  147. >
  148. {loading ? <Loader2 size={18} className="animate-spin" /> : <>{t('bind_email.send_btn')} <ArrowRight size={18} /></>}
  149. </button>
  150. </form>
  151. ) : (
  152. <form onSubmit={handleVerifyAndBind} className="space-y-5">
  153. <p className="text-sm text-gray-500">
  154. {t('bind_email.desc_step2_prefix')} <span className="font-bold text-gray-900">{email}</span>{t('bind_email.desc_step2_suffix')}
  155. </p>
  156. <div>
  157. <label className="block text-xs font-bold text-slate-500 uppercase mb-1">{t('bind_email.code_label')}</label>
  158. <div className="flex gap-3">
  159. <input
  160. type="text" required
  161. className="flex-1 border border-slate-300 rounded-lg p-3 text-sm focus:ring-2 focus:ring-blue-500 outline-none font-mono text-center tracking-[0.25em] text-lg font-bold"
  162. placeholder="------"
  163. maxLength={6}
  164. value={code}
  165. onChange={(e) => setCode(e.target.value)}
  166. />
  167. <button
  168. type="button"
  169. disabled={countdown > 0 || loading}
  170. onClick={() => handleSendCode()}
  171. className="w-32 border border-slate-300 bg-gray-50 text-slate-600 rounded-lg text-xs font-medium hover:bg-white hover:border-blue-300 hover:text-blue-600 disabled:opacity-50 disabled:bg-slate-100 disabled:cursor-not-allowed transition"
  172. >
  173. {countdown > 0 ? `${countdown}${t('bind_email.resend_suffix')}` : t('bind_email.resend_btn')}
  174. </button>
  175. </div>
  176. </div>
  177. <div className="flex gap-3 pt-2">
  178. <button
  179. type="button"
  180. onClick={() => setStep(1)}
  181. className="px-4 py-2 text-slate-500 hover:text-slate-700 flex items-center gap-1 text-sm font-medium transition"
  182. >
  183. <ArrowLeft size={16} /> {t('bind_email.change_email_btn')}
  184. </button>
  185. <button
  186. type="submit"
  187. disabled={loading}
  188. className="flex-1 bg-blue-600 text-white py-3 rounded-lg font-bold hover:bg-blue-700 transition flex justify-center items-center gap-2 disabled:opacity-50 shadow-md shadow-blue-100"
  189. >
  190. {loading ? <Loader2 size={18} className="animate-spin" /> : <><Save size={18} /> {t('bind_email.confirm_btn')}</>}
  191. </button>
  192. </div>
  193. </form>
  194. )}
  195. </div>
  196. </div>
  197. {/* 3. 挂载消息弹窗 */}
  198. <MessageModal
  199. isOpen={msgModal.isOpen}
  200. title={msgModal.title}
  201. message={msgModal.message}
  202. type={msgModal.type}
  203. onClose={handleCloseMsg}
  204. />
  205. </div>
  206. );
  207. }