FirstLoginPage.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. import { useEffect, useState, useRef } from 'react';
  2. import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
  3. import { message } from 'antd';
  4. import { setStorage, STORAGE_KEYS, getUserId, getVisitedRoutes, setUserName } from '@/utils/storage';
  5. import { updateUserUIState, getQQAuthUrl, getUserInfo } from '@/utils/api';
  6. import { px2vmin } from '@/utils/viewport';
  7. import { getOssUrl } from '@/utils/oss';
  8. // const giftProgressBar = getOssUrl('gift-progress-bar.png'); // Unused
  9. // const wechatLogin = getOssUrl('wechat-login.png');
  10. const qqLogin = getOssUrl('qq-login.png');
  11. const phoneLogin = getOssUrl('phone-login.png');
  12. const emailLogin = getOssUrl('email-login.png');
  13. const continueBtn = getOssUrl('continue-btn.png');
  14. const continueBtnA = getOssUrl('continue-btn-a.png');
  15. const focusGradient =
  16. 'linear-gradient(90deg, #FFC900 11%, #13A6FF 32%, #806EFF 50%, #FF8EEC 66%, #FE4F51 89%)';
  17. interface LocationState {
  18. loginType?: 'phone' | 'qq' | 'email';
  19. areaCode?: string;
  20. phoneNumber?: string;
  21. email?: string;
  22. }
  23. export const FirstLoginPage = () => {
  24. const location = useLocation();
  25. const navigate = useNavigate();
  26. const [searchParams] = useSearchParams();
  27. const state = location.state as LocationState | null;
  28. // 根据传入的数据初始化状态
  29. const [areaCode, setAreaCode] = useState(state?.areaCode || '+86');
  30. const [phoneNumber, setPhoneNumber] = useState(state?.phoneNumber || '');
  31. const [email, setEmail] = useState(state?.email || '');
  32. const [isAreaCodeFocused, setIsAreaCodeFocused] = useState(false);
  33. const [isPhoneFocused, setIsPhoneFocused] = useState(false);
  34. const [isEmailFocused, setIsEmailFocused] = useState(false);
  35. const [selectedLoginType, setSelectedLoginType] = useState<'phone' | 'qq' | 'email'>(
  36. state?.loginType || 'phone'
  37. );
  38. const [isAreaCodeOpen, setIsAreaCodeOpen] = useState(false);
  39. const areaCodeContainerRef = useRef<HTMLDivElement>(null);
  40. const phoneRef = useRef<HTMLInputElement>(null);
  41. const emailRef = useRef<HTMLInputElement>(null);
  42. const areaCodeOptions = [
  43. { code: '+86', label: '中国', flag: getOssUrl('china.png') },
  44. { code: '+44', label: '英国', flag: getOssUrl('england.png') },
  45. ];
  46. useEffect(() => {
  47. const handleClickOutside = (event: MouseEvent) => {
  48. if (areaCodeContainerRef.current && !areaCodeContainerRef.current.contains(event.target as Node)) {
  49. setIsAreaCodeOpen(false);
  50. setIsAreaCodeFocused(false);
  51. }
  52. };
  53. document.addEventListener('mousedown', handleClickOutside);
  54. return () => document.removeEventListener('mousedown', handleClickOutside);
  55. }, []);
  56. useEffect(() => {
  57. // 记录用户当前所在页面
  58. setStorage(STORAGE_KEYS.CURRENT_PAGE, 'first-login');
  59. const visited = getVisitedRoutes();
  60. const userId = getUserId();
  61. if (userId) {
  62. updateUserUIState(userId, {
  63. current_page: 'first-login',
  64. visited_routes: visited,
  65. }).catch(() => {
  66. // 同步失败不影响前端流程
  67. });
  68. }
  69. }, []);
  70. // 处理QQ OAuth回调
  71. useEffect(() => {
  72. const oauth = searchParams.get('oauth');
  73. const userId = searchParams.get('user_id');
  74. const usernameParam = searchParams.get('username');
  75. const error = searchParams.get('error');
  76. if (error === 'qq_auth_failed') {
  77. message.error('QQ登录失败,请重试');
  78. // 清除URL参数
  79. navigate('/first-login', { replace: true });
  80. return;
  81. }
  82. if (error === 'user_not_registered' && oauth === 'qq') {
  83. // 用户不存在,但这是从 first-login 来的,应该自动注册
  84. // 这个情况应该由后端处理,如果后端没有处理,这里可以提示用户
  85. message.error('QQ用户未注册,请使用其他方式登录');
  86. navigate('/first-login', { replace: true });
  87. return;
  88. }
  89. if (oauth === 'qq' && userId && usernameParam) {
  90. // QQ登录/注册成功,保存用户信息并完成登录流程
  91. const handleQQLoginSuccess = async () => {
  92. try {
  93. // 保存基本用户信息
  94. setStorage(STORAGE_KEYS.USER_ID, userId);
  95. setStorage(STORAGE_KEYS.USER_NAME, decodeURIComponent(usernameParam));
  96. // 设置登录状态
  97. setStorage(STORAGE_KEYS.IS_LOGGED_IN, 'true');
  98. // 获取完整用户信息并同步到 localStorage(刷新数据)
  99. try {
  100. const userInfo = await getUserInfo(userId);
  101. if (userInfo.success && userInfo.data) {
  102. // 同步用户名
  103. if (userInfo.data.username) {
  104. setUserName(userInfo.data.username);
  105. }
  106. // 同步机器人名字
  107. if (userInfo.data.bot_name) {
  108. setStorage(STORAGE_KEYS.LUMI_NAME, userInfo.data.bot_name);
  109. }
  110. // 同步用户等级
  111. if (typeof userInfo.data.user_level === 'number') {
  112. setStorage(STORAGE_KEYS.USER_LEVEL, String(userInfo.data.user_level));
  113. }
  114. // 背景图片会在 getUserInfo 中自动同步
  115. }
  116. } catch (error) {
  117. // 获取完整用户信息失败不影响登录流程,静默处理
  118. console.warn('获取完整用户信息失败:', error);
  119. }
  120. message.success('QQ登录成功!已获得帽子奖励');
  121. // 清除URL参数并跳转到奖励页面
  122. navigate('/first-login-reward', { replace: true });
  123. } catch (error) {
  124. console.error('处理QQ登录成功回调失败:', error);
  125. message.error('登录处理失败,请重试');
  126. navigate('/first-login', { replace: true });
  127. }
  128. };
  129. handleQQLoginSuccess();
  130. }
  131. }, [searchParams, navigate]);
  132. return (
  133. <div>
  134. {/* 顶部进度条图片 */}
  135. <img
  136. src={getOssUrl('01-no-gift.gif')}
  137. alt="gift progress bar"
  138. style={{
  139. position: 'absolute',
  140. left: '50%',
  141. top: px2vmin(64),
  142. width: px2vmin(195),
  143. transform: 'translateX(-50%)',
  144. }}
  145. />
  146. {/* 中部 GIF 动画图片 */}
  147. <img
  148. src={getOssUrl('03-Cheer.gif')}
  149. alt="cheer animation"
  150. style={{
  151. position: 'absolute',
  152. left: '50%',
  153. top: px2vmin(132),
  154. height: px2vmin(184),
  155. transform: 'translateX(-50%)',
  156. }}
  157. />
  158. {/* 输入框 */}
  159. <div
  160. className="absolute"
  161. style={{
  162. top: px2vmin(326),
  163. left: '50%',
  164. transform: 'translateX(-50%)',
  165. width: px2vmin(312),
  166. display: 'flex',
  167. gap: px2vmin(6),
  168. zIndex: 20,
  169. }}
  170. >
  171. {/* 手机登录:区号 + 手机号 */}
  172. {selectedLoginType === 'phone' && (
  173. <>
  174. {/* 区号下拉选择 */}
  175. <div style={{ width: px2vmin(95), position: 'relative' }} ref={areaCodeContainerRef}>
  176. <div
  177. className="relative overflow-visible"
  178. style={{
  179. height: px2vmin(56),
  180. background: 'linear-gradient(180deg, #26232F 0%, #18151F 100%)',
  181. borderTopLeftRadius: '12PX',
  182. borderBottomLeftRadius: '2PX',
  183. borderTopRightRadius: '12PX',
  184. borderBottomRightRadius: '2PX',
  185. boxShadow:
  186. '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)',
  187. }}
  188. >
  189. <button
  190. type="button"
  191. onClick={() => {
  192. setIsAreaCodeOpen((prev) => !prev);
  193. setIsAreaCodeFocused(true);
  194. }}
  195. className="w-full h-full flex items-center justify-between bg-transparent border-none outline-none px-0"
  196. style={{
  197. paddingLeft: px2vmin(12),
  198. paddingRight: px2vmin(8),
  199. fontSize: px2vmin(16),
  200. color: '#FFFFFF',
  201. gap: px2vmin(8),
  202. cursor: 'pointer',
  203. }}
  204. >
  205. <div className="flex items-center" style={{ gap: px2vmin(8) }}>
  206. <img
  207. src={
  208. areaCodeOptions.find((item) => item.code === areaCode)?.flag ||
  209. getOssUrl('china.png')
  210. }
  211. alt="area flag"
  212. style={{
  213. width: px2vmin(16),
  214. height: px2vmin(24),
  215. objectFit: 'cover',
  216. }}
  217. />
  218. <span style={{ color: '#FFFFFF' }}>{areaCode}</span>
  219. </div>
  220. <img
  221. src={getOssUrl('arrow-icon.png')}
  222. alt="arrow"
  223. style={{ width: px2vmin(7.5), height: px2vmin(3.75), flexShrink: 0 }}
  224. />
  225. </button>
  226. {isAreaCodeOpen && (
  227. <div
  228. style={{
  229. position: 'absolute',
  230. top: '100%',
  231. left: 0,
  232. marginTop: px2vmin(4),
  233. width: px2vmin(95),
  234. background: '#1E1A26',
  235. borderRadius: '12PX',
  236. boxShadow: '0 8PX 20PX rgba(0, 0, 0, 0.45)',
  237. zIndex: 10,
  238. overflow: 'hidden',
  239. }}
  240. >
  241. {areaCodeOptions
  242. .filter((item) => item.code !== areaCode)
  243. .map((item) => (
  244. <button
  245. key={item.code}
  246. type="button"
  247. onClick={() => {
  248. setAreaCode(item.code);
  249. setIsAreaCodeOpen(false);
  250. setIsAreaCodeFocused(false);
  251. }}
  252. className="flex items-center justify-start w-full border-none bg-transparent text-left hover:opacity-80 transition-opacity"
  253. style={{
  254. padding: `${px2vmin(10)} ${px2vmin(12)}`,
  255. gap: px2vmin(10),
  256. cursor: 'pointer',
  257. }}
  258. >
  259. <img
  260. src={item.flag}
  261. alt={`${item.label} flag`}
  262. style={{
  263. width: px2vmin(16),
  264. height: px2vmin(24),
  265. objectFit: 'cover',
  266. }}
  267. />
  268. <span style={{ color: '#FFFFFF', fontSize: px2vmin(16) }}>{item.code}</span>
  269. </button>
  270. ))}
  271. </div>
  272. )}
  273. </div>
  274. </div>
  275. {/* 手机号输入框 */}
  276. <div style={{ flex: 1, position: 'relative' }}>
  277. <div
  278. className="relative overflow-visible"
  279. style={{
  280. height: px2vmin(56),
  281. background: 'rgba(0,0,0,0.1)',
  282. borderTopRightRadius: '12PX',
  283. borderBottomRightRadius: '2PX',
  284. borderTopLeftRadius: '12PX',
  285. borderBottomLeftRadius: '2PX',
  286. boxShadow:
  287. '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',
  288. }}
  289. >
  290. <input
  291. ref={phoneRef}
  292. type="text"
  293. inputMode="numeric"
  294. 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)]"
  295. style={{
  296. fontSize: px2vmin(16),
  297. paddingLeft: px2vmin(24),
  298. paddingRight: px2vmin(24),
  299. }}
  300. placeholder="请输入手机号码"
  301. value={phoneNumber}
  302. onChange={(e) => setPhoneNumber(e.target.value)}
  303. onFocus={() => setIsPhoneFocused(true)}
  304. onBlur={() => setIsPhoneFocused(false)}
  305. />
  306. <button
  307. className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
  308. style={{
  309. right: px2vmin(12),
  310. width: px2vmin(20),
  311. height: px2vmin(20),
  312. opacity: isPhoneFocused && phoneNumber ? 1 : 0.6,
  313. }}
  314. onClick={() => setPhoneNumber('')}
  315. aria-label="清除"
  316. >
  317. <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
  318. <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
  319. <path
  320. d="M8 8L16 16M16 8L8 16"
  321. stroke="rgba(191, 157, 248, 1)"
  322. strokeWidth="2.5"
  323. strokeLinecap="round"
  324. />
  325. </svg>
  326. </button>
  327. </div>
  328. <div
  329. className="w-full transition-opacity duration-300"
  330. style={{
  331. height: '2PX',
  332. background: isAreaCodeFocused || isPhoneFocused
  333. ? focusGradient
  334. : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  335. }}
  336. ></div>
  337. </div>
  338. </>
  339. )}
  340. {/* 邮箱登录:邮箱输入框 */}
  341. {selectedLoginType === 'email' && (
  342. <div style={{ width: '100%', position: 'relative' }}>
  343. <div
  344. className="relative overflow-visible"
  345. style={{
  346. height: px2vmin(56),
  347. background: 'rgba(0,0,0,0.1)',
  348. borderTopLeftRadius: '12PX',
  349. borderTopRightRadius: '12PX',
  350. borderBottomLeftRadius: '2PX',
  351. borderBottomRightRadius: '2PX',
  352. boxShadow:
  353. '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',
  354. }}
  355. >
  356. <input
  357. ref={emailRef}
  358. type="text"
  359. 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)]"
  360. style={{
  361. fontSize: px2vmin(16),
  362. paddingLeft: px2vmin(24),
  363. paddingRight: px2vmin(24),
  364. }}
  365. placeholder="请输入邮箱地址"
  366. value={email}
  367. onChange={(e) => setEmail(e.target.value)}
  368. onFocus={() => setIsEmailFocused(true)}
  369. onBlur={() => setIsEmailFocused(false)}
  370. />
  371. <button
  372. className="absolute top-1/2 -translate-y-1/2 flex items-center justify-center transition-opacity touch-manipulation"
  373. style={{
  374. right: px2vmin(12),
  375. width: px2vmin(20),
  376. height: px2vmin(20),
  377. opacity: isEmailFocused && email ? 1 : 0.6,
  378. }}
  379. onClick={() => setEmail('')}
  380. aria-label="清除"
  381. >
  382. <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
  383. <circle cx="12" cy="12" r="11" stroke="rgba(191, 157, 248, 1)" strokeWidth="2.5" fill="none" />
  384. <path
  385. d="M8 8L16 16M16 8L8 16"
  386. stroke="rgba(191, 157, 248, 1)"
  387. strokeWidth="2.5"
  388. strokeLinecap="round"
  389. />
  390. </svg>
  391. </button>
  392. </div>
  393. <div
  394. className="w-full transition-opacity duration-300"
  395. style={{
  396. height: '2PX',
  397. background: isEmailFocused ? focusGradient : 'var(--Schemes-Primary, rgba(103, 80, 164, 1))',
  398. }}
  399. ></div>
  400. </div>
  401. )}
  402. {/* QQ登录:文字提示 */}
  403. {selectedLoginType === 'qq' && (
  404. <div
  405. style={{
  406. width: '100%',
  407. textAlign: 'center',
  408. color: '#FFFFFF',
  409. fontSize: px2vmin(24),
  410. fontWeight: 'bold',
  411. }}
  412. >
  413. QQ登录
  414. </div>
  415. )}
  416. </div>
  417. {/* Continue 按钮 */}
  418. <button
  419. type="button"
  420. onClick={() => {
  421. // 手机号码或邮箱登录时,跳转到验证码页面
  422. if (selectedLoginType === 'phone' && phoneNumber) {
  423. navigate('/verification-code', {
  424. state: {
  425. loginType: 'phone',
  426. areaCode,
  427. phoneNumber,
  428. },
  429. });
  430. } else if (selectedLoginType === 'email' && email) {
  431. navigate('/verification-code', {
  432. state: {
  433. loginType: 'email',
  434. email,
  435. },
  436. });
  437. } else if (selectedLoginType === 'qq') {
  438. // QQ登录:跳转到QQ授权页面(传递来源信息)
  439. try {
  440. const qqAuthUrl = getQQAuthUrl('first-login');
  441. window.location.href = qqAuthUrl;
  442. } catch (error) {
  443. message.error('QQ登录失败,请重试');
  444. }
  445. }
  446. }}
  447. style={{
  448. position: 'absolute',
  449. left: '50%',
  450. top: px2vmin(436),
  451. width: px2vmin(279),
  452. transform: 'translateX(-50%)',
  453. border: 'none',
  454. padding: 0,
  455. background: 'none',
  456. }}
  457. >
  458. <img
  459. src={
  460. (selectedLoginType === 'phone' && phoneNumber) ||
  461. (selectedLoginType === 'email' && email) ||
  462. selectedLoginType === 'qq'
  463. ? continueBtnA
  464. : continueBtn
  465. }
  466. alt="continue"
  467. style={{
  468. width: '100%',
  469. display: 'block',
  470. }}
  471. />
  472. </button>
  473. {/* 微信登录按钮 */}
  474. {/* <button
  475. type="button"
  476. onClick={() => navigate('/first-login-reward')}
  477. style={{
  478. position: 'absolute',
  479. left: '50%',
  480. top: px2vmin(567),
  481. width: px2vmin(279),
  482. transform: 'translateX(-50%)',
  483. border: 'none',
  484. padding: 0,
  485. background: 'none',
  486. }}
  487. >
  488. <img
  489. src={wechatLogin}
  490. alt="wechat login"
  491. style={{
  492. width: '100%',
  493. display: 'block',
  494. }}
  495. />
  496. </button> */}
  497. {/* 其他登录方式文字 */}
  498. <div
  499. style={{
  500. position: 'absolute',
  501. left: '50%',
  502. top: px2vmin(562),
  503. transform: 'translateX(-50%)',
  504. color: '#F5F3F0',
  505. fontSize: px2vmin(12),
  506. whiteSpace: 'nowrap',
  507. }}
  508. >
  509. —— 其他登陆方式 ——
  510. </div>
  511. {/* 按钮行:QQ / 手机 / 邮箱 */}
  512. <div
  513. style={{
  514. position: 'absolute',
  515. left: '50%',
  516. top: px2vmin(590),
  517. transform: 'translateX(-50%)',
  518. display: 'flex',
  519. gap: px2vmin(32),
  520. alignItems: 'center',
  521. justifyContent: 'center',
  522. width: 'fit-content',
  523. }}
  524. >
  525. {selectedLoginType !== 'qq' && (
  526. <img
  527. src={qqLogin}
  528. alt="qq login"
  529. onClick={() => setSelectedLoginType('qq')}
  530. style={{
  531. width: px2vmin(40),
  532. height: px2vmin(40),
  533. cursor: 'pointer',
  534. }}
  535. />
  536. )}
  537. {selectedLoginType !== 'phone' && (
  538. <img
  539. src={phoneLogin}
  540. alt="phone login"
  541. onClick={() => setSelectedLoginType('phone')}
  542. style={{
  543. width: px2vmin(40),
  544. height: px2vmin(40),
  545. cursor: 'pointer',
  546. }}
  547. />
  548. )}
  549. {selectedLoginType !== 'email' && (
  550. <img
  551. src={emailLogin}
  552. alt="email login"
  553. onClick={() => setSelectedLoginType('email')}
  554. style={{
  555. width: px2vmin(40),
  556. height: px2vmin(40),
  557. cursor: 'pointer',
  558. }}
  559. />
  560. )}
  561. </div>
  562. </div>
  563. );
  564. };