LoginPage.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524
  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, loginWithPhone, loginWithEmail, getQQAuthUrl } from '@/utils/api';
  6. import { px2vmin, px2vw, px2vh } from '@/utils/viewport';
  7. import { getOssUrl } from '@/utils/oss';
  8. const qqLogin = getOssUrl('qq-login.png');
  9. const phoneLogin = getOssUrl('phone-login.png');
  10. const emailLogin = getOssUrl('email-login.png');
  11. const continueBtn = getOssUrl('continue-btn.png');
  12. const continueBtnA = getOssUrl('continue-btn-a.png');
  13. const backButton = getOssUrl('backbutn.png');
  14. interface LocationState {
  15. loginType?: 'phone' | 'qq' | 'email';
  16. areaCode?: string;
  17. phoneNumber?: string;
  18. email?: string;
  19. }
  20. export const LoginPage = () => {
  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, setSelectedLoginType] = useState<'phone' | 'qq' | 'email'>(
  32. state?.loginType || 'phone'
  33. );
  34. const [isChecking, setIsChecking] = useState(false);
  35. const areaCodeRef = useRef<HTMLInputElement>(null);
  36. const phoneRef = useRef<HTMLInputElement>(null);
  37. const emailRef = useRef<HTMLInputElement>(null);
  38. useEffect(() => {
  39. // 记录用户当前所在页面
  40. setStorage(STORAGE_KEYS.CURRENT_PAGE, 'login');
  41. const visited = getVisitedRoutes();
  42. const userId = getUserId();
  43. if (userId) {
  44. updateUserUIState(userId, {
  45. current_page: 'login',
  46. visited_routes: visited,
  47. }).catch(() => {
  48. // 同步失败不影响前端流程
  49. });
  50. }
  51. }, []);
  52. const handleBack = () => {
  53. navigate('/welcome');
  54. };
  55. // 处理 continue 按钮点击
  56. const handleContinue = async () => {
  57. // 手机号登录
  58. if (selectedLoginType === 'phone' && phoneNumber) {
  59. if (isChecking) return;
  60. setIsChecking(true);
  61. try {
  62. // 检查用户是否存在
  63. await loginWithPhone(phoneNumber);
  64. // 用户存在,跳转到验证码页面
  65. navigate('/login-verification-code', {
  66. state: {
  67. loginType: 'phone',
  68. areaCode,
  69. phoneNumber,
  70. email, // 保留邮箱数据,以便返回时恢复
  71. },
  72. });
  73. } catch (error) {
  74. // 用户不存在,提示并返回 welcome 页面
  75. const errorMessage = error instanceof Error ? error.message : '登录失败';
  76. if (errorMessage.includes('404') || errorMessage.includes('用户不存在')) {
  77. message.error('用户未注册,请先注册');
  78. navigate('/welcome');
  79. } else {
  80. message.error(errorMessage);
  81. }
  82. } finally {
  83. setIsChecking(false);
  84. }
  85. }
  86. // 邮箱登录
  87. else if (selectedLoginType === 'email' && email) {
  88. if (isChecking) return;
  89. setIsChecking(true);
  90. try {
  91. // 检查用户是否存在
  92. await loginWithEmail(email);
  93. // 用户存在,跳转到验证码页面
  94. navigate('/login-verification-code', {
  95. state: {
  96. loginType: 'email',
  97. areaCode, // 保留区号数据,以便返回时恢复
  98. phoneNumber, // 保留手机号数据,以便返回时恢复
  99. email,
  100. },
  101. });
  102. } catch (error) {
  103. // 用户不存在,提示并返回 welcome 页面
  104. const errorMessage = error instanceof Error ? error.message : '登录失败';
  105. if (errorMessage.includes('404') || errorMessage.includes('用户不存在')) {
  106. message.error('用户未注册,请先注册');
  107. navigate('/welcome');
  108. } else {
  109. message.error(errorMessage);
  110. }
  111. } finally {
  112. setIsChecking(false);
  113. }
  114. }
  115. // QQ登录
  116. else if (selectedLoginType === 'qq') {
  117. // 跳转到QQ授权页面
  118. const qqAuthUrl = getQQAuthUrl();
  119. window.location.href = qqAuthUrl;
  120. }
  121. };
  122. return (
  123. <div>
  124. {/* 顶部返回按钮 */}
  125. <button
  126. type="button"
  127. onClick={handleBack}
  128. style={{
  129. position: 'absolute',
  130. left: px2vmin(16),
  131. top: px2vmin(56),
  132. width: px2vmin(48),
  133. height: px2vmin(48),
  134. padding: 0,
  135. border: 'none',
  136. background: 'none',
  137. zIndex: 10,
  138. cursor: 'pointer',
  139. }}
  140. aria-label="Back"
  141. >
  142. <img
  143. src={backButton}
  144. alt="Back"
  145. style={{
  146. width: '100%',
  147. height: '100%',
  148. display: 'block',
  149. pointerEvents: 'none',
  150. }}
  151. />
  152. </button>
  153. {/* 欢迎文案 */}
  154. <div
  155. style={{
  156. position: 'absolute',
  157. left: '50%',
  158. top: px2vmin(111),
  159. width: px2vmin(270),
  160. transform: 'translateX(-50%)',
  161. fontSize: px2vmin(26),
  162. textAlign: 'center',
  163. lineHeight: 1.4,
  164. whiteSpace: 'normal',
  165. background: 'linear-gradient(97.3deg, rgba(245, 243, 240, 0.8) 2.21%, rgba(245, 243, 240, 0.3) 150.89%)',
  166. WebkitBackgroundClip: 'text',
  167. WebkitTextFillColor: 'transparent',
  168. backgroundClip: 'text',
  169. }}
  170. >
  171. Welcome back! It&rsquo;s time to chat with{' '}
  172. <span
  173. style={{
  174. background: 'none',
  175. WebkitBackgroundClip: 'initial',
  176. WebkitTextFillColor: '#BF9DF8',
  177. color: '#BF9DF8',
  178. }}
  179. >
  180. Lumi
  181. </span>{' '}
  182. again
  183. </div>
  184. {/* 中部 GIF 动画图片 */}
  185. <img
  186. src="https://lyrria-pro.oss-cn-beijing.aliyuncs.com/01-Default.gif"
  187. alt="cheer animation"
  188. style={{
  189. position: 'absolute',
  190. left: '50%',
  191. top: px2vmin(182),
  192. height: px2vmin(184),
  193. transform: 'translateX(-50%)',
  194. }}
  195. />
  196. {/* 输入框 */}
  197. <div
  198. className="absolute"
  199. style={{
  200. top: px2vmin(376),
  201. left: '50%',
  202. transform: 'translateX(-50%)',
  203. width: px2vmin(312),
  204. display: 'flex',
  205. gap: 0,
  206. }}
  207. >
  208. {/* 手机登录:区号 + 手机号 */}
  209. {selectedLoginType === 'phone' && (
  210. <>
  211. {/* 区号输入框 */}
  212. <div style={{ width: px2vmin(80), position: 'relative' }}>
  213. <div
  214. className="relative overflow-visible"
  215. style={{
  216. height: px2vmin(56),
  217. background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
  218. borderTopLeftRadius: '12PX',
  219. borderBottomLeftRadius: '2PX',
  220. borderTopRightRadius: 0,
  221. borderBottomRightRadius: 0,
  222. boxShadow:
  223. '0PX 1PX 0PX 0PX rgba(255, 255, 255, 0.18), inset 0PX 0PX 0PX 1PX rgba(0, 0, 0, 0.65), inset 0PX 4PX 8PX rgba(0, 0, 0, 0.55)',
  224. }}
  225. >
  226. <input
  227. ref={areaCodeRef}
  228. type="text"
  229. 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)] text-center"
  230. style={{
  231. fontSize: px2vmin(16),
  232. paddingLeft: px2vw(8),
  233. paddingRight: px2vw(8),
  234. }}
  235. placeholder="+86"
  236. value={areaCode}
  237. onChange={(e) => {
  238. let value = e.target.value;
  239. // 确保始终以 + 开头
  240. if (value && !value.startsWith('+')) {
  241. value = '+' + value;
  242. }
  243. setAreaCode(value);
  244. }}
  245. onFocus={() => setIsAreaCodeFocused(true)}
  246. onBlur={() => setIsAreaCodeFocused(false)}
  247. />
  248. {/* 右侧白色竖线 */}
  249. <div
  250. style={{
  251. position: 'absolute',
  252. right: 0,
  253. top: px2vmin(12),
  254. bottom: px2vmin(12),
  255. width: '1PX',
  256. background: 'rgba(255, 255, 255, 1)',
  257. }}
  258. ></div>
  259. </div>
  260. <div
  261. className="w-full transition-opacity duration-300"
  262. style={{
  263. height: '2PX',
  264. background: isAreaCodeFocused || isPhoneFocused
  265. ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
  266. : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  267. }}
  268. ></div>
  269. </div>
  270. {/* 手机号输入框 */}
  271. <div style={{ flex: 1, position: 'relative' }}>
  272. <div
  273. className="relative overflow-visible"
  274. style={{
  275. height: px2vmin(56),
  276. background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
  277. borderTopRightRadius: '12PX',
  278. borderBottomRightRadius: '2PX',
  279. borderTopLeftRadius: 0,
  280. borderBottomLeftRadius: 0,
  281. boxShadow:
  282. '0PX 1PX 0PX 0PX rgba(255, 255, 255, 0.18), inset 0PX 0PX 0PX 1PX rgba(0, 0, 0, 0.65), inset 0PX 4PX 8PX rgba(0, 0, 0, 0.55)',
  283. }}
  284. >
  285. <input
  286. ref={phoneRef}
  287. type="text"
  288. 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)]"
  289. style={{
  290. fontSize: px2vmin(16),
  291. paddingLeft: px2vw(24),
  292. paddingRight: px2vmin(24),
  293. }}
  294. placeholder="请输入手机号码"
  295. value={phoneNumber}
  296. onChange={(e) => setPhoneNumber(e.target.value)}
  297. onFocus={() => setIsPhoneFocused(true)}
  298. onBlur={() => setIsPhoneFocused(false)}
  299. />
  300. {phoneNumber && (
  301. <button
  302. className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity hover:opacity-70 touch-manipulation"
  303. style={{
  304. right: px2vw(12),
  305. width: px2vw(20),
  306. height: px2vh(20),
  307. }}
  308. onClick={() => setPhoneNumber('')}
  309. aria-label="清除"
  310. >
  311. <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
  312. <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
  313. <path
  314. d="M8 8L16 16M16 8L8 16"
  315. stroke="rgba(191, 157, 248, 1)"
  316. strokeWidth="2.5"
  317. strokeLinecap="round"
  318. />
  319. </svg>
  320. </button>
  321. )}
  322. </div>
  323. <div
  324. className="w-full transition-opacity duration-300"
  325. style={{
  326. height: '2PX',
  327. background: isAreaCodeFocused || isPhoneFocused
  328. ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
  329. : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  330. }}
  331. ></div>
  332. </div>
  333. </>
  334. )}
  335. {/* 邮箱登录:邮箱输入框 */}
  336. {selectedLoginType === 'email' && (
  337. <div style={{ width: '100%', position: 'relative' }}>
  338. <div
  339. className="relative overflow-visible"
  340. style={{
  341. height: px2vmin(56),
  342. background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
  343. borderTopLeftRadius: '12PX',
  344. borderTopRightRadius: '12PX',
  345. borderBottomLeftRadius: '2PX',
  346. borderBottomRightRadius: '2PX',
  347. boxShadow:
  348. '0PX 1PX 0PX 0PX rgba(255, 255, 255, 0.18), inset 0PX 0PX 0PX 1PX rgba(0, 0, 0, 0.65), inset 0PX 4PX 8PX rgba(0, 0, 0, 0.55)',
  349. }}
  350. >
  351. <input
  352. ref={emailRef}
  353. type="text"
  354. 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)]"
  355. style={{
  356. fontSize: px2vmin(16),
  357. paddingLeft: px2vw(24),
  358. paddingRight: px2vmin(24),
  359. }}
  360. placeholder="请输入邮箱地址"
  361. value={email}
  362. onChange={(e) => setEmail(e.target.value)}
  363. onFocus={() => setIsEmailFocused(true)}
  364. onBlur={() => setIsEmailFocused(false)}
  365. />
  366. {email && (
  367. <button
  368. className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity hover:opacity-70 touch-manipulation"
  369. style={{
  370. right: px2vw(12),
  371. width: px2vw(20),
  372. height: px2vh(20),
  373. }}
  374. onClick={() => setEmail('')}
  375. aria-label="清除"
  376. >
  377. <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
  378. <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
  379. <path
  380. d="M8 8L16 16M16 8L8 16"
  381. stroke="rgba(191, 157, 248, 1)"
  382. strokeWidth="2.5"
  383. strokeLinecap="round"
  384. />
  385. </svg>
  386. </button>
  387. )}
  388. </div>
  389. <div
  390. className="w-full transition-opacity duration-300"
  391. style={{
  392. height: '2PX',
  393. background: isEmailFocused
  394. ? 'linear-gradient(270deg, #F67B7C 0%, #FFBEF4 29.64%, #A699FE 49.41%, #84CCF8 74.11%, #FFDD5E 98.81%)'
  395. : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  396. }}
  397. ></div>
  398. </div>
  399. )}
  400. {/* QQ登录:文字提示 */}
  401. {selectedLoginType === 'qq' && (
  402. <div
  403. style={{
  404. width: '100%',
  405. textAlign: 'center',
  406. color: '#FFFFFF',
  407. fontSize: px2vmin(24),
  408. fontWeight: 'bold',
  409. }}
  410. >
  411. QQ登录
  412. </div>
  413. )}
  414. </div>
  415. {/* Continue 按钮 */}
  416. <button
  417. type="button"
  418. onClick={handleContinue}
  419. disabled={isChecking}
  420. style={{
  421. position: 'absolute',
  422. left: '50%',
  423. top: px2vmin(486),
  424. width: px2vmin(279),
  425. transform: 'translateX(-50%)',
  426. border: 'none',
  427. padding: 0,
  428. background: 'none',
  429. }}
  430. >
  431. <img
  432. src={
  433. (selectedLoginType === 'phone' && phoneNumber) ||
  434. (selectedLoginType === 'email' && email) ||
  435. selectedLoginType === 'qq'
  436. ? continueBtnA
  437. : continueBtn
  438. }
  439. alt="continue"
  440. style={{
  441. width: '100%',
  442. display: 'block',
  443. }}
  444. />
  445. </button>
  446. {/* 其他登录方式文字 */}
  447. <div
  448. style={{
  449. position: 'absolute',
  450. left: '50%',
  451. top: px2vmin(612),
  452. transform: 'translateX(-50%)',
  453. color: '#F5F3F0',
  454. fontSize: px2vmin(12),
  455. whiteSpace: 'nowrap',
  456. }}
  457. >
  458. —— 其他登陆方式 ——
  459. </div>
  460. {/* 按钮行:QQ / 手机 / 邮箱 */}
  461. <div
  462. style={{
  463. position: 'absolute',
  464. left: '50%',
  465. top: px2vmin(640),
  466. transform: 'translateX(-50%)',
  467. display: 'flex',
  468. gap: px2vmin(32),
  469. alignItems: 'center',
  470. justifyContent: 'center',
  471. width: 'fit-content',
  472. }}
  473. >
  474. {selectedLoginType !== 'qq' && (
  475. <img
  476. src={qqLogin}
  477. alt="qq login"
  478. onClick={() => setSelectedLoginType('qq')}
  479. style={{
  480. width: px2vmin(40),
  481. height: px2vmin(40),
  482. cursor: 'pointer',
  483. }}
  484. />
  485. )}
  486. {selectedLoginType !== 'phone' && (
  487. <img
  488. src={phoneLogin}
  489. alt="phone login"
  490. onClick={() => setSelectedLoginType('phone')}
  491. style={{
  492. width: px2vmin(40),
  493. height: px2vmin(40),
  494. cursor: 'pointer',
  495. }}
  496. />
  497. )}
  498. {selectedLoginType !== 'email' && (
  499. <img
  500. src={emailLogin}
  501. alt="email login"
  502. onClick={() => setSelectedLoginType('email')}
  503. style={{
  504. width: px2vmin(40),
  505. height: px2vmin(40),
  506. cursor: 'pointer',
  507. }}
  508. />
  509. )}
  510. </div>
  511. </div>
  512. );
  513. };