VerificationCodePage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476
  1. import { useEffect, useState, useRef } from 'react';
  2. import { useLocation, useNavigate } from 'react-router-dom';
  3. import { message } from 'antd';
  4. import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes } from '@/utils/storage';
  5. import { updateUserUIState, sendCode } from '@/utils/api';
  6. import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
  7. import { getOssUrl } from '@/utils/oss';
  8. // const giftProgressBar = getOssUrl('gift-progress-bar.png'); // Unused
  9. const sendCodeNo = getOssUrl('send-code-no.png');
  10. const sendCodeImage = getOssUrl('send-code.png');
  11. const backButton = getOssUrl('backbutn.png');
  12. const focusGradient =
  13. 'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
  14. interface LocationState {
  15. loginType?: 'phone' | 'email';
  16. areaCode?: string;
  17. phoneNumber?: string;
  18. email?: string;
  19. }
  20. export const VerificationCodePage = () => {
  21. const location = useLocation();
  22. const navigate = useNavigate();
  23. const state = location.state as LocationState | null;
  24. // 根据传入的数据初始化状态
  25. const [areaCode, setAreaCode] = useState(state?.areaCode || '+86');
  26. const [phoneNumber, setPhoneNumber] = useState(state?.phoneNumber || '');
  27. const [email, setEmail] = useState(state?.email || '');
  28. const [isAreaCodeFocused, setIsAreaCodeFocused] = useState(false);
  29. const [isPhoneFocused, setIsPhoneFocused] = useState(false);
  30. const [isEmailFocused, setIsEmailFocused] = useState(false);
  31. const [selectedLoginType] = useState<'phone' | 'qq' | 'email'>(state?.loginType || 'phone');
  32. const [isAreaCodeOpen, setIsAreaCodeOpen] = useState(false);
  33. const areaCodeContainerRef = useRef<HTMLDivElement>(null);
  34. const phoneRef = useRef<HTMLInputElement>(null);
  35. const emailRef = useRef<HTMLInputElement>(null);
  36. const areaCodeOptions = [
  37. { code: '+86', label: '中国', flag: getOssUrl('china.png') },
  38. { code: '+44', label: '英国', flag: getOssUrl('england.png') },
  39. ];
  40. useEffect(() => {
  41. // 记录用户当前所在页面
  42. setStorage(STORAGE_KEYS.CURRENT_PAGE, 'verification-code');
  43. const visited = getVisitedRoutes();
  44. const userId = getUserId();
  45. if (userId) {
  46. updateUserUIState(userId, {
  47. current_page: 'verification-code',
  48. visited_routes: visited,
  49. }).catch(() => {
  50. // 同步失败不影响前端流程
  51. });
  52. }
  53. }, []);
  54. useEffect(() => {
  55. const handleClickOutside = (event: MouseEvent) => {
  56. if (areaCodeContainerRef.current && !areaCodeContainerRef.current.contains(event.target as Node)) {
  57. setIsAreaCodeOpen(false);
  58. setIsAreaCodeFocused(false);
  59. }
  60. };
  61. document.addEventListener('mousedown', handleClickOutside);
  62. return () => document.removeEventListener('mousedown', handleClickOutside);
  63. }, []);
  64. const handleBack = () => {
  65. navigate('/first-login', {
  66. state: {
  67. loginType: selectedLoginType,
  68. areaCode,
  69. phoneNumber,
  70. email,
  71. },
  72. });
  73. };
  74. // 处理发送验证码按钮点击
  75. const handleSendCode = async () => {
  76. // 验证输入
  77. if (selectedLoginType === 'phone' && !phoneNumber) {
  78. message.error('请输入手机号码');
  79. return;
  80. }
  81. if (selectedLoginType === 'email' && !email) {
  82. message.error('请输入邮箱地址');
  83. return;
  84. }
  85. try {
  86. // 确定发送类型
  87. const sendType = selectedLoginType === 'email' ? 'email' : 'domestic_sms';
  88. // 发送验证码
  89. const response = await sendCode({
  90. area_code: selectedLoginType === 'phone' ? areaCode : undefined,
  91. phone: selectedLoginType === 'phone' ? phoneNumber : undefined,
  92. email: selectedLoginType === 'email' ? email : undefined,
  93. send_type: sendType,
  94. });
  95. if (response.success) {
  96. message.success('验证码已发送');
  97. // 跳转到验证码输入页面
  98. navigate('/verification-input', {
  99. state: {
  100. loginType: selectedLoginType,
  101. areaCode,
  102. phoneNumber,
  103. email,
  104. },
  105. });
  106. } else {
  107. message.error('发送验证码失败,请重试');
  108. }
  109. } catch (error) {
  110. const errorMessage = error instanceof Error ? error.message : '发送验证码失败';
  111. message.error(errorMessage);
  112. }
  113. };
  114. return (
  115. <div>
  116. {/* 顶部返回按钮 */}
  117. <button
  118. type="button"
  119. onClick={handleBack}
  120. style={{
  121. position: 'absolute',
  122. left: px2vmin(16),
  123. top: px2vmin(56),
  124. width: px2vmin(48),
  125. height: px2vmin(48),
  126. padding: 0,
  127. border: 'none',
  128. background: 'none',
  129. zIndex: 10,
  130. cursor: 'pointer',
  131. }}
  132. aria-label="Back"
  133. >
  134. <img
  135. src={backButton}
  136. alt="Back"
  137. style={{
  138. width: '100%',
  139. height: '100%',
  140. display: 'block',
  141. pointerEvents: 'none',
  142. }}
  143. />
  144. </button>
  145. {/* 顶部进度条图片 */}
  146. <img
  147. src={getOssUrl('01-no-gift.gif')}
  148. alt="gift progress bar"
  149. style={{
  150. position: 'absolute',
  151. left: '50%',
  152. top: px2vmin(64),
  153. width: px2vmin(195),
  154. transform: 'translateX(-50%)',
  155. }}
  156. />
  157. {/* 中部 GIF 动画图片 */}
  158. <img
  159. src={getOssUrl('03-Cheer.gif')}
  160. alt="cheer animation"
  161. style={{
  162. position: 'absolute',
  163. left: '50%',
  164. top: px2vmin(132),
  165. height: px2vmin(184),
  166. transform: 'translateX(-50%)',
  167. }}
  168. />
  169. {/* 输入框 */}
  170. <div
  171. className="absolute"
  172. style={{
  173. top: px2vmin(326),
  174. left: '50%',
  175. transform: 'translateX(-50%)',
  176. width: px2vmin(312),
  177. display: 'flex',
  178. gap: px2vmin(6),
  179. zIndex: 20,
  180. }}
  181. >
  182. {/* 手机登录:区号 + 手机号 */}
  183. {selectedLoginType === 'phone' && (
  184. <>
  185. {/* 区号下拉选择 */}
  186. <div style={{ width: px2vmin(95), position: 'relative' }} ref={areaCodeContainerRef}>
  187. <div
  188. className="relative overflow-visible"
  189. style={{
  190. height: px2vmin(56),
  191. background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
  192. borderTopLeftRadius: '12PX',
  193. borderBottomLeftRadius: '2PX',
  194. borderTopRightRadius: '12PX',
  195. borderBottomRightRadius: '2PX',
  196. boxShadow:
  197. 'rgba(255, 255, 255, 0.18) 0px 1px 0px 0px, rgba(0, 0, 0, 0.65) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.25) 0px 4px 8px inset',
  198. }}
  199. >
  200. <button
  201. type="button"
  202. onClick={() => {
  203. setIsAreaCodeOpen((prev) => !prev);
  204. setIsAreaCodeFocused(true);
  205. }}
  206. className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
  207. style={{
  208. paddingLeft: px2vmin(12),
  209. paddingRight: px2vmin(8),
  210. fontSize: px2vmin(16),
  211. color: '#FFFFFF',
  212. gap: px2vmin(8),
  213. cursor: 'pointer',
  214. }}
  215. >
  216. <div className="flex items-center" style={{ gap: px2vmin(8) }}>
  217. <img
  218. src={
  219. areaCodeOptions.find((item) => item.code === areaCode)?.flag ||
  220. getOssUrl('china.png')
  221. }
  222. alt="area flag"
  223. style={{
  224. width: px2vmin(16),
  225. height: px2vmin(24),
  226. objectFit: 'cover',
  227. }}
  228. />
  229. <span style={{ color: '#FFFFFF' }}>{areaCode}</span>
  230. </div>
  231. <img
  232. src={getOssUrl('arrow-icon.png')}
  233. alt="arrow"
  234. style={{ width: px2vmin(7.5), height: px2vmin(3.75), flexShrink: 0 }}
  235. />
  236. </button>
  237. {isAreaCodeOpen && (
  238. <div
  239. style={{
  240. position: 'absolute',
  241. top: '100%',
  242. left: 0,
  243. marginTop: px2vmin(4),
  244. width: px2vmin(95),
  245. background: '#1E1A26',
  246. borderRadius: '12PX',
  247. boxShadow: '0 8PX 20PX rgba(0, 0, 0, 0.45)',
  248. zIndex: 10,
  249. overflow: 'hidden',
  250. }}
  251. >
  252. {areaCodeOptions
  253. .filter((item) => item.code !== areaCode)
  254. .map((item) => (
  255. <button
  256. key={item.code}
  257. type="button"
  258. onClick={() => {
  259. setAreaCode(item.code);
  260. setIsAreaCodeOpen(false);
  261. setIsAreaCodeFocused(false);
  262. }}
  263. className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
  264. style={{
  265. padding: `${px2vmin(10)} ${px2vmin(12)}`,
  266. gap: px2vmin(10),
  267. cursor: 'pointer',
  268. }}
  269. >
  270. <img
  271. src={item.flag}
  272. alt={`${item.label} flag`}
  273. style={{
  274. width: px2vmin(16),
  275. height: px2vmin(24),
  276. objectFit: 'cover',
  277. }}
  278. />
  279. <span style={{ color: '#FFFFFF', fontSize: px2vmin(16) }}>{item.code}</span>
  280. </button>
  281. ))}
  282. </div>
  283. )}
  284. </div>
  285. </div>
  286. {/* 手机号输入框 */}
  287. <div style={{ flex: 1, position: 'relative' }}>
  288. <div
  289. className="relative overflow-visible"
  290. style={{
  291. height: px2vmin(56),
  292. background: 'rgba(0,0,0,0.1)',
  293. borderTopRightRadius: '12PX',
  294. borderBottomRightRadius: '2PX',
  295. borderTopLeftRadius: '12PX',
  296. borderBottomLeftRadius: '2PX',
  297. boxShadow:
  298. 'rgba(255, 255, 255, 0.18) 0px 1px 0px 0px, rgba(0, 0, 0, 0.65) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.25) 0px 4px 8px inset',
  299. }}
  300. >
  301. <input
  302. ref={phoneRef}
  303. type="text"
  304. inputMode="numeric"
  305. className="w-full h-full font-normal leading-normal tracking-[0.5px] text-[rgba(245,243,240,0.7)] bg-transparent border-none outline-none placeholder:text-[rgba(245,243,240,0.45)]"
  306. style={{
  307. fontSize: px2vmin(16),
  308. paddingLeft: px2vmin(24),
  309. paddingRight: px2vmin(24),
  310. }}
  311. placeholder="请输入手机号码"
  312. value={phoneNumber}
  313. onChange={(e) => setPhoneNumber(e.target.value)}
  314. onFocus={() => setIsPhoneFocused(true)}
  315. onBlur={() => setIsPhoneFocused(false)}
  316. />
  317. <button
  318. className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
  319. style={{
  320. right: px2vmin(12),
  321. width: px2vmin(20),
  322. height: px2vmin(20),
  323. opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
  324. }}
  325. onClick={() => setPhoneNumber('')}
  326. aria-label="清除"
  327. >
  328. <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
  329. <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
  330. <path
  331. d="M8 8L16 16M16 8L8 16"
  332. stroke="rgba(191, 157, 248, 1)"
  333. strokeWidth="2.5"
  334. strokeLinecap="round"
  335. />
  336. </svg>
  337. </button>
  338. </div>
  339. <div
  340. className="w-full transition-opacity duration-300"
  341. style={{
  342. height: '2PX',
  343. background: isAreaCodeFocused || isPhoneFocused
  344. ? focusGradient
  345. : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  346. }}
  347. ></div>
  348. </div>
  349. </>
  350. )}
  351. {/* 邮箱登录:邮箱输入框 */}
  352. {selectedLoginType === 'email' && (
  353. <div style={{ width: '100%', position: 'relative' }}>
  354. <div
  355. className="relative overflow-visible"
  356. style={{
  357. height: px2vmin(56),
  358. background: 'rgba(0,0,0,0.1)',
  359. borderTopLeftRadius: '12PX',
  360. borderTopRightRadius: '12PX',
  361. borderBottomLeftRadius: '2PX',
  362. borderBottomRightRadius: '2PX',
  363. boxShadow:
  364. 'rgba(255, 255, 255, 0.18) 0px 1px 0px 0px, rgba(0, 0, 0, 0.65) 0px 0px 0px 1px inset, rgba(0, 0, 0, 0.25) 0px 4px 8px inset',
  365. }}
  366. >
  367. <input
  368. ref={emailRef}
  369. type="text"
  370. className="w-full h-full font-normal leading-normal tracking-[0.5px] text-[rgba(245,243,240,0.7)] bg-transparent border-none outline-none placeholder:text-[rgba(245,243,240,0.45)]"
  371. style={{
  372. fontSize: px2vmin(16),
  373. paddingLeft: px2vw(24),
  374. paddingRight: px2vmin(24),
  375. }}
  376. placeholder="请输入邮箱地址"
  377. value={email}
  378. onChange={(e) => setEmail(e.target.value)}
  379. onFocus={() => setIsEmailFocused(true)}
  380. onBlur={() => setIsEmailFocused(false)}
  381. />
  382. <button
  383. className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
  384. style={{
  385. right: px2vw(12),
  386. width: px2vw(20),
  387. height: px2vh(20),
  388. opacity: isEmailFocused && email ? 1 : 0.6,
  389. }}
  390. onClick={() => setEmail('')}
  391. aria-label="清除"
  392. >
  393. <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
  394. <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
  395. <path
  396. d="M8 8L16 16M16 8L8 16"
  397. stroke="rgba(191, 157, 248, 1)"
  398. strokeWidth="2.5"
  399. strokeLinecap="round"
  400. />
  401. </svg>
  402. </button>
  403. </div>
  404. <div
  405. className="w-full transition-opacity duration-300"
  406. style={{
  407. height: '2PX',
  408. background: isEmailFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  409. }}
  410. ></div>
  411. </div>
  412. )}
  413. {/* QQ登录:文字提示 */}
  414. {selectedLoginType === 'qq' && (
  415. <div
  416. style={{
  417. width: '100%',
  418. textAlign: 'center',
  419. color: '#FFFFFF',
  420. fontSize: px2vmin(24),
  421. fontWeight: 'bold',
  422. }}
  423. >
  424. QQ登录
  425. </div>
  426. )}
  427. </div>
  428. {/* 发送验证码按钮 */}
  429. <button
  430. type="button"
  431. onClick={handleSendCode}
  432. style={{
  433. position: 'absolute',
  434. left: '50%',
  435. top: px2vmin(436),
  436. width: px2vmin(279),
  437. transform: 'translateX(-50%)',
  438. border: 'none',
  439. padding: 0,
  440. background: 'none',
  441. cursor: (selectedLoginType === 'phone' && phoneNumber) || (selectedLoginType === 'email' && email) ? 'pointer' : 'not-allowed',
  442. }}
  443. >
  444. <img
  445. src={
  446. (selectedLoginType === 'phone' && phoneNumber) ||
  447. (selectedLoginType === 'email' && email)
  448. ? sendCodeImage
  449. : sendCodeNo
  450. }
  451. alt="send code"
  452. style={{
  453. width: '100%',
  454. display: 'block',
  455. }}
  456. />
  457. </button>
  458. </div>
  459. );
  460. };