FirstLoginRewardPage.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656
  1. import { useEffect, useRef, useState, type TouchEvent } from 'react';
  2. import { useNavigate } from 'react-router-dom';
  3. import { message } from 'antd';
  4. import {
  5. setStorage,
  6. STORAGE_KEYS,
  7. getUserId,
  8. getVisitedRoutes,
  9. setCurrentBackgroundImageId,
  10. setCurrentBackgroundImageUrl,
  11. syncBackgroundImageToStorage,
  12. setHasSharedAvatar,
  13. } from '@/utils/storage';
  14. import { updateUserUIState, claimShareReward } from '@/utils/api';
  15. import { px2vmin, px2vw } from '@/utils/viewport';
  16. import { getOssUrl } from '@/utils/oss';
  17. const backButton = getOssUrl('backbutn.png');
  18. const shareProgressBar = getOssUrl('share-progress-bar.png');
  19. const avatarAnimaZone = getOssUrl('avatar-anima-zone-copy.png');
  20. const shareBtn = getOssUrl('share-btn.png');
  21. const closeBtn = getOssUrl('close-btn.png');
  22. // const wechatIcon = getOssUrl('wechat.png'); // 已注释,未使用
  23. // const qqIcon = getOssUrl('QQ.png'); // 已注释,未使用
  24. // const saveIcon = getOssUrl('save.png'); // 已注释,未使用
  25. const saveBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/share-done-claim-gift.png';
  26. const claimGiftBtnImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/claim-the-gift.png';
  27. const chevronLeftImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/Chevron-L.png';
  28. const chevronRightImage = 'https://lyrria-pro.oss-cn-beijing.aliyuncs.com/Chevron-R.png';
  29. import { shareImages as fallbackShareImages } from '@/data/shareImages';
  30. import { getShareImages, type ShareImageItem } from '@/utils/api';
  31. export const FirstLoginRewardPage = () => {
  32. const navigate = useNavigate();
  33. const [showShareModal, setShowShareModal] = useState(false);
  34. const [shareImages, setShareImages] = useState<ShareImageItem[]>([]);
  35. const [activeShareIndex, setActiveShareIndex] = useState(0);
  36. const touchStartXRef = useRef<number | null>(null);
  37. useEffect(() => {
  38. // 记录用户当前所在页面
  39. setStorage(STORAGE_KEYS.CURRENT_PAGE, 'first-login-reward');
  40. const visited = getVisitedRoutes();
  41. const userId = getUserId();
  42. if (userId) {
  43. updateUserUIState(userId, {
  44. current_page: 'first-login-reward',
  45. visited_routes: visited,
  46. }).catch(() => {
  47. // 同步失败不影响前端流程
  48. });
  49. }
  50. }, []);
  51. useEffect(() => {
  52. const fetchShareImages = async () => {
  53. try {
  54. const res = await getShareImages();
  55. if (res.success && res.data?.length) {
  56. setShareImages(res.data);
  57. setActiveShareIndex(0);
  58. return;
  59. }
  60. } catch (error) {
  61. // fallback below
  62. }
  63. setShareImages(fallbackShareImages);
  64. setActiveShareIndex(0);
  65. };
  66. fetchShareImages();
  67. }, []);
  68. // 预加载分享图片
  69. useEffect(() => {
  70. if (shareImages.length === 0) {
  71. return;
  72. }
  73. const preloadImages = () => {
  74. shareImages.forEach((item) => {
  75. if (item.url) {
  76. const img = new Image();
  77. img.src = item.url;
  78. }
  79. });
  80. };
  81. preloadImages();
  82. }, [shareImages]);
  83. const handleBack = () => {
  84. navigate('/chatbot');
  85. };
  86. const handleShareClick = () => {
  87. if (!shareImages.length) {
  88. return;
  89. }
  90. setShowShareModal(true);
  91. };
  92. const handleShareClose = () => {
  93. setShowShareModal(false);
  94. setActiveShareIndex(0);
  95. };
  96. // 领取分享奖励
  97. const handleSaveImage = async () => {
  98. try {
  99. const userId = getUserId();
  100. if (!userId) {
  101. message.error('用户未登录,请先登录');
  102. return;
  103. }
  104. message.loading('正在领取分享奖励...', 0);
  105. // 调用分享奖励接口
  106. const result = await claimShareReward(userId);
  107. message.destroy();
  108. if (result.success && result.data) {
  109. // 显式更新 localStorage 中的背景图片相关变量
  110. const backgroundImageId = result.data.background_image_id || 'happynewyear';
  111. const backgroundImageMetadata = result.data.background_image_metadata || { opacity: 0.5 };
  112. // 同步背景图片到 localStorage(包括 ID、URL 和 metadata)
  113. syncBackgroundImageToStorage(backgroundImageId, backgroundImageMetadata);
  114. // 确保背景图片 ID 和 URL 都已更新
  115. setCurrentBackgroundImageId(backgroundImageId);
  116. // 获取背景图片 URL(从映射中获取)
  117. const { getBackgroundImageUrlById } = await import('@/utils/storage');
  118. const backgroundImageUrl = getBackgroundImageUrlById(backgroundImageId);
  119. if (backgroundImageUrl) {
  120. setCurrentBackgroundImageUrl(backgroundImageUrl);
  121. }
  122. // 更新分享头像状态:更新 localStorage
  123. // 注意:数据库中的 has_shared_avatar 已在 claimShareReward 接口中更新
  124. setHasSharedAvatar(true);
  125. // 额外同步一次确保数据一致性(claimShareReward 接口已更新,这里作为双重保障)
  126. try {
  127. await updateUserUIState(userId, {
  128. has_shared_avatar: true,
  129. });
  130. console.log('✅ 分享头像状态已同步到数据库');
  131. } catch (error) {
  132. console.error('⚠️ 同步分享头像状态到数据库失败(claimShareReward 接口已更新,不影响主流程):', error);
  133. // 即使同步失败,claimShareReward 接口已经更新了数据库,不影响主流程
  134. }
  135. message.success('分享奖励已领取!新年背景已解锁');
  136. // 跳转到分享成功页面
  137. setTimeout(() => {
  138. navigate('/share-success');
  139. }, 1000);
  140. } else {
  141. message.error(result.message || '领取分享奖励失败,请重试');
  142. }
  143. } catch (error) {
  144. console.error('领取分享奖励失败:', error);
  145. message.destroy();
  146. message.error('领取分享奖励失败,请重试');
  147. }
  148. };
  149. const handleTouchStart = (event: TouchEvent<HTMLDivElement>) => {
  150. touchStartXRef.current = event.touches[0]?.clientX ?? null;
  151. };
  152. const handleTouchEnd = (event: TouchEvent<HTMLDivElement>) => {
  153. const startX = touchStartXRef.current;
  154. if (startX === null) {
  155. return;
  156. }
  157. if (!shareImages.length) {
  158. return;
  159. }
  160. const endX = event.changedTouches[0]?.clientX ?? startX;
  161. const deltaX = endX - startX;
  162. const threshold = 30;
  163. if (deltaX > threshold && activeShareIndex > 0) {
  164. setActiveShareIndex((prev) => Math.max(prev - 1, 0));
  165. } else if (deltaX < -threshold && activeShareIndex < shareImages.length - 1) {
  166. setActiveShareIndex((prev) => Math.min(prev + 1, shareImages.length - 1));
  167. }
  168. touchStartXRef.current = null;
  169. };
  170. const handlePreviousImage = () => {
  171. if (activeShareIndex > 0) {
  172. setActiveShareIndex((prev) => prev - 1);
  173. }
  174. };
  175. const handleNextImage = () => {
  176. if (activeShareIndex < shareImages.length - 1) {
  177. setActiveShareIndex((prev) => prev + 1);
  178. }
  179. };
  180. useEffect(() => {
  181. if (showShareModal) {
  182. const previousOverflow = document.body.style.overflow;
  183. document.body.style.overflow = 'hidden';
  184. return () => {
  185. document.body.style.overflow = previousOverflow;
  186. };
  187. }
  188. return undefined;
  189. }, [showShareModal]);
  190. return (
  191. <div>
  192. {/* 顶部返回按钮 */}
  193. <button
  194. type="button"
  195. onClick={handleBack}
  196. style={{
  197. position: 'absolute',
  198. left: px2vmin(16),
  199. top: px2vmin(56),
  200. width: px2vmin(48),
  201. height: px2vmin(48),
  202. padding: 0,
  203. border: 'none',
  204. background: 'none',
  205. }}
  206. aria-label="Back"
  207. >
  208. <img
  209. src={backButton}
  210. alt="Back"
  211. style={{
  212. width: '100%',
  213. height: '100%',
  214. display: 'block',
  215. }}
  216. />
  217. </button>
  218. {/* 顶部进度条 */}
  219. <img
  220. src={shareProgressBar}
  221. alt="share progress bar"
  222. style={{
  223. position: 'absolute',
  224. left: '50%',
  225. top: px2vmin(61),
  226. width: px2vmin(195),
  227. transform: 'translateX(-50%)',
  228. }}
  229. />
  230. {/* 中部头像动画区域 */}
  231. <img
  232. src={avatarAnimaZone}
  233. alt="avatar anima zone"
  234. style={{
  235. position: 'absolute',
  236. left: '50%',
  237. top: px2vmin(192),
  238. width: px2vmin(343),
  239. transform: 'translateX(-50%)',
  240. }}
  241. />
  242. {!showShareModal && (
  243. <>
  244. {/* 标题文案 */}
  245. <div
  246. style={{
  247. position: 'absolute',
  248. left: '50%',
  249. top: px2vmin(504),
  250. transform: 'translateX(-50%)',
  251. color: '#FFFFFF',
  252. fontSize: px2vmin(24),
  253. fontWeight: 'bold',
  254. whiteSpace: 'nowrap',
  255. }}
  256. >
  257. Congratulation!
  258. </div>
  259. {/* 描述文案 */}
  260. <div
  261. style={{
  262. position: 'absolute',
  263. left: '50%',
  264. top: px2vmin(552),
  265. transform: 'translateX(-50%)',
  266. color: '#FFFFFF',
  267. fontSize: px2vmin(14),
  268. textAlign: 'center',
  269. width: px2vmin(230),
  270. }}
  271. >
  272. 哇,是一顶小帽子,谢谢你!要到新年了,快来分享我的新装扮并解锁限定新年背景吧
  273. </div>
  274. {/* 分享按钮 */}
  275. <button
  276. type="button"
  277. onClick={handleShareClick}
  278. style={{
  279. position: 'absolute',
  280. left: '50%',
  281. top: px2vmin(656),
  282. transform: 'translateX(-50%)',
  283. border: 'none',
  284. padding: 0,
  285. background: 'none',
  286. width: px2vmin(279),
  287. height: px2vmin(56),
  288. }}
  289. >
  290. <img
  291. src={shareBtn}
  292. alt="Share"
  293. style={{
  294. width: '100%',
  295. height: '100%',
  296. display: 'block',
  297. }}
  298. />
  299. </button>
  300. </>
  301. )}
  302. {showShareModal && (
  303. <>
  304. {/* 分享图片区域 - 在弹窗上方显示 */}
  305. <div
  306. style={{
  307. position: 'fixed',
  308. inset: 0,
  309. zIndex: 9997,
  310. display: 'flex',
  311. justifyContent: 'center',
  312. alignItems: 'flex-start',
  313. paddingTop: px2vmin(118),
  314. pointerEvents: 'none',
  315. }}
  316. >
  317. <div
  318. style={{
  319. position: 'relative',
  320. width: px2vw(375),
  321. display: 'flex',
  322. justifyContent: 'center',
  323. }}
  324. >
  325. {shareImages.length > 0 && (
  326. <div
  327. style={{
  328. position: 'relative',
  329. display: 'flex',
  330. alignItems: 'center',
  331. justifyContent: 'center',
  332. }}
  333. >
  334. {/* 图片容器 */}
  335. <div
  336. onTouchStart={handleTouchStart}
  337. onTouchEnd={handleTouchEnd}
  338. style={{
  339. position: 'relative',
  340. width: px2vmin(311),
  341. display: 'flex',
  342. justifyContent: 'center',
  343. overflow: 'visible',
  344. pointerEvents: 'auto',
  345. }}
  346. >
  347. {/* 左侧按钮 */}
  348. {activeShareIndex > 0 && (
  349. <button
  350. type="button"
  351. onClick={handlePreviousImage}
  352. style={{
  353. position: 'absolute',
  354. left: px2vmin(-12),
  355. top: '50%',
  356. transform: 'translateY(-50%)',
  357. zIndex: 10001,
  358. border: 'none',
  359. padding: 0,
  360. background: 'none',
  361. width: px2vmin(24),
  362. height: px2vmin(24),
  363. cursor: 'pointer',
  364. pointerEvents: 'auto',
  365. display: 'flex',
  366. alignItems: 'center',
  367. justifyContent: 'center',
  368. }}
  369. aria-label="Previous image"
  370. >
  371. <img
  372. src={chevronLeftImage}
  373. alt="Previous"
  374. style={{
  375. width: '100%',
  376. height: '100%',
  377. display: 'block',
  378. }}
  379. />
  380. </button>
  381. )}
  382. <button
  383. type="button"
  384. onClick={handleShareClose}
  385. style={{
  386. position: 'absolute',
  387. right: 0,
  388. top: 0,
  389. transform: 'translate(-10%, -130%)',
  390. width: px2vmin(30),
  391. height: px2vmin(30),
  392. padding: 0,
  393. border: 'none',
  394. background: 'none',
  395. pointerEvents: 'auto',
  396. zIndex: 10000,
  397. }}
  398. aria-label="Close share modal"
  399. >
  400. <img
  401. src={closeBtn}
  402. alt="Close"
  403. style={{ width: '100%', height: '100%', display: 'block' }}
  404. />
  405. </button>
  406. <div
  407. style={{
  408. position: 'relative',
  409. width: '100%',
  410. display: 'flex',
  411. justifyContent: 'center',
  412. overflow: 'visible',
  413. }}
  414. >
  415. {activeShareIndex > 0 && (
  416. <img
  417. src={shareImages[activeShareIndex - 1]?.url}
  418. alt="Previous share preview"
  419. aria-hidden
  420. style={{
  421. position: 'absolute',
  422. height: 'auto',
  423. maxWidth: '100%',
  424. opacity: 1,
  425. pointerEvents: 'none',
  426. zIndex: 0,
  427. }}
  428. />
  429. )}
  430. {activeShareIndex < shareImages.length - 1 && (
  431. <img
  432. src={shareImages[activeShareIndex + 1]?.url}
  433. alt="Next share preview"
  434. aria-hidden
  435. style={{
  436. position: 'absolute',
  437. height: 'auto',
  438. maxWidth: '100%',
  439. opacity: 1,
  440. pointerEvents: 'none',
  441. zIndex: 0,
  442. }}
  443. />
  444. )}
  445. <img
  446. src={shareImages[activeShareIndex]?.url}
  447. alt="Share preview"
  448. style={{
  449. width: '100%',
  450. height: 'auto',
  451. display: 'block',
  452. borderRadius: px2vmin(12),
  453. boxShadow: 'none',
  454. position: 'relative',
  455. zIndex: 1,
  456. }}
  457. />
  458. </div>
  459. {/* 右侧按钮 */}
  460. {activeShareIndex < shareImages.length - 1 && (
  461. <button
  462. type="button"
  463. onClick={handleNextImage}
  464. style={{
  465. position: 'absolute',
  466. right: px2vmin(-12),
  467. top: '50%',
  468. transform: 'translateY(-50%)',
  469. zIndex: 10001,
  470. border: 'none',
  471. padding: 0,
  472. background: 'none',
  473. width: px2vmin(24),
  474. height: px2vmin(24),
  475. cursor: 'pointer',
  476. pointerEvents: 'auto',
  477. display: 'flex',
  478. alignItems: 'center',
  479. justifyContent: 'center',
  480. }}
  481. aria-label="Next image"
  482. >
  483. <img
  484. src={chevronRightImage}
  485. alt="Next"
  486. style={{
  487. width: '100%',
  488. height: '100%',
  489. display: 'block',
  490. }}
  491. />
  492. </button>
  493. )}
  494. </div>
  495. </div>
  496. )}
  497. {shareImages.length > 0 && (
  498. <div
  499. style={{
  500. position: 'absolute',
  501. top: px2vmin(514),
  502. left: '50%',
  503. transform: 'translateX(-50%)',
  504. display: 'flex',
  505. alignItems: 'center',
  506. gap: px2vmin(8),
  507. pointerEvents: 'auto',
  508. }}
  509. >
  510. {shareImages.map((item, index) => {
  511. const isActive = index === activeShareIndex;
  512. return (
  513. <button
  514. key={item.id}
  515. type="button"
  516. aria-label={`Select share image ${index + 1}`}
  517. onClick={() => setActiveShareIndex(index)}
  518. style={{
  519. width: isActive ? px2vmin(24) : px2vmin(6),
  520. height: px2vmin(6),
  521. borderRadius: isActive ? px2vmin(20) : px2vmin(6),
  522. background: isActive ? 'rgba(169, 132, 228, 1)' : 'rgba(232, 222, 248, 1)',
  523. border: 'none',
  524. padding: 0,
  525. }}
  526. />
  527. );
  528. })}
  529. </div>
  530. )}
  531. </div>
  532. </div>
  533. {/* 底部弹窗 */}
  534. {shareImages.length > 0 && (
  535. <div
  536. style={{
  537. position: 'fixed',
  538. bottom: 0,
  539. left: '50%',
  540. transform: 'translateX(-50%)',
  541. width: px2vw(375),
  542. height: px2vmin(162),
  543. backgroundColor: '#34313D',
  544. borderTopLeftRadius: px2vmin(32),
  545. borderTopRightRadius: px2vmin(32),
  546. zIndex: 9999,
  547. display: 'flex',
  548. flexDirection: 'column',
  549. alignItems: 'center',
  550. paddingTop: px2vmin(12),
  551. paddingBottom: px2vmin(12),
  552. }}
  553. >
  554. {/* 顶部拖拽指示条 */}
  555. <div
  556. style={{
  557. width: px2vmin(32),
  558. height: px2vmin(4),
  559. backgroundColor: '#79747E',
  560. borderRadius: px2vmin(4),
  561. flexShrink: 0,
  562. }}
  563. />
  564. {/* 提示文字和按钮 */}
  565. <div
  566. style={{
  567. flex: 1,
  568. display: 'flex',
  569. flexDirection: 'column',
  570. alignItems: 'center',
  571. justifyContent: 'center',
  572. width: '100%',
  573. }}
  574. >
  575. {/* 提示文字 */}
  576. <div
  577. style={{
  578. color: '#FFFFFF',
  579. textAlign: 'center',
  580. lineHeight: px2vmin(14),
  581. marginBottom: px2vmin(18),
  582. }}
  583. >
  584. 长按上方图片保存到相册后领取奖励
  585. </div>
  586. {/* 领取奖励按钮 */}
  587. <button
  588. type="button"
  589. onClick={handleSaveImage}
  590. style={{
  591. border: 'none',
  592. padding: 0,
  593. background: 'none',
  594. // width: px2vmin(118),
  595. height: px2vmin(35),
  596. cursor: 'pointer',
  597. }}
  598. >
  599. <img
  600. src={claimGiftBtnImage}
  601. alt="Claim Gift"
  602. style={{
  603. height: '100%',
  604. display: 'block',
  605. }}
  606. />
  607. </button>
  608. </div>
  609. </div>
  610. )}
  611. </>
  612. )}
  613. </div>
  614. );
  615. };