LotteryResultModal.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. import { Image } from 'expo-image';
  2. import React, { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
  3. import {
  4. ActivityIndicator,
  5. Animated,
  6. Dimensions,
  7. ImageBackground,
  8. Modal,
  9. ScrollView,
  10. StyleSheet,
  11. Text,
  12. TouchableOpacity,
  13. View
  14. } from 'react-native';
  15. import { useSafeAreaInsets } from 'react-native-safe-area-context';
  16. import { convertApply, getApplyResult } from '@/services/award';
  17. const { width: SCREEN_WIDTH } = Dimensions.get('window');
  18. const CARD_WIDTH = (SCREEN_WIDTH - 60) / 3;
  19. const CARD_HEIGHT = CARD_WIDTH * 1.5;
  20. const CDN_BASE = 'https://cdn.acetoys.cn';
  21. const imgUrl = `${CDN_BASE}/kai_xin_ma_te/supermart`;
  22. const imgUrlSupermart = `${CDN_BASE}/supermart`;
  23. const LEVEL_MAP: Record<string, { title: string; color: string; rgba: string; resultBg: string; borderImg: string }> = {
  24. A: {
  25. title: '超神款',
  26. color: '#F62C71',
  27. rgba: 'rgba(246, 44, 113, 1)',
  28. resultBg: `${imgUrlSupermart}/supermart/box/resultBgA.png`,
  29. borderImg: `${imgUrlSupermart}/supermart/box/borderImgA.png`
  30. },
  31. B: {
  32. title: '欧皇款',
  33. color: '#E9C525',
  34. rgba: 'rgba(233,197,37, 1)',
  35. resultBg: `${imgUrlSupermart}/supermart/box/resultBgB.png`,
  36. borderImg: `${imgUrlSupermart}/supermart/box/borderImgB.png`
  37. },
  38. C: {
  39. title: '隐藏款',
  40. color: '#A72CF6',
  41. rgba: 'rgba(167, 44, 246, 1)',
  42. resultBg: `${imgUrlSupermart}/supermart/box/resultBgC.png`,
  43. borderImg: `${imgUrlSupermart}/supermart/box/borderImgC.png`
  44. },
  45. D: {
  46. title: '普通款',
  47. color: '#40c9d7',
  48. rgba: 'rgba(64, 201, 215, 1)',
  49. resultBg: `${imgUrlSupermart}/supermart/box/resultBgD.png`,
  50. borderImg: `${imgUrlSupermart}/supermart/box/borderImgD.png`
  51. },
  52. };
  53. const LotteryImages = {
  54. lotteryBg: `${imgUrlSupermart}/supermart/box/sequence/sequence0.jpg`,
  55. cardBack: `${imgUrl}/box/back1.png`,
  56. halo: `${imgUrlSupermart}/supermart/box/halo.gif`,
  57. };
  58. interface LotteryItem {
  59. id: string;
  60. name: string;
  61. cover: string;
  62. level: string;
  63. magicAmount?: number;
  64. spu?: { marketPrice: number };
  65. }
  66. export interface LotteryResultModalRef {
  67. show: (tradeNo: string) => void;
  68. close: () => void;
  69. }
  70. interface LotteryResultModalProps {
  71. onClose?: () => void;
  72. onGoStore?: () => void;
  73. }
  74. export const LotteryResultModal = forwardRef<LotteryResultModalRef, LotteryResultModalProps>(
  75. ({ onClose, onGoStore }, ref) => {
  76. const insets = useSafeAreaInsets();
  77. const [visible, setVisible] = useState(false);
  78. const [loading, setLoading] = useState(true);
  79. const [tableData, setTableData] = useState<LotteryItem[]>([]);
  80. const [total, setTotal] = useState(0);
  81. const [showResult, setShowResult] = useState(false);
  82. const [showDh, setShowDh] = useState(false);
  83. const [haloShow, setHaloShow] = useState(false);
  84. const [rebateAmount, setRebateAmount] = useState(0);
  85. const [animationEnabled, setAnimationEnabled] = useState(true);
  86. const [isSkip, setIsSkip] = useState(true);
  87. const [tradeNo, setTradeNo] = useState('');
  88. const flipAnims = useRef<Animated.Value[]>([]);
  89. const dataLoadedRef = useRef(false);
  90. useImperativeHandle(ref, () => ({
  91. show: (tNo: string) => {
  92. setTradeNo(tNo);
  93. setVisible(true);
  94. setLoading(true);
  95. setTableData([]);
  96. setTotal(0);
  97. setShowResult(false);
  98. setShowDh(false);
  99. setHaloShow(false);
  100. setRebateAmount(0);
  101. setIsSkip(true);
  102. dataLoadedRef.current = false;
  103. flipAnims.current = [];
  104. },
  105. close: () => {
  106. setVisible(false);
  107. dataLoadedRef.current = false;
  108. },
  109. }));
  110. const flipCards = (data: LotteryItem[]) => {
  111. setIsSkip(false);
  112. setHaloShow(true);
  113. setTimeout(() => setHaloShow(false), 1000);
  114. const maxCards = Math.min(data.length, 9);
  115. const animations = flipAnims.current.slice(0, maxCards).map((anim, index) => {
  116. return Animated.sequence([
  117. Animated.delay(index * 200),
  118. Animated.timing(anim, { toValue: 1, duration: 200, useNativeDriver: true }),
  119. ]);
  120. });
  121. Animated.parallel(animations).start(() => {
  122. setTimeout(() => {
  123. setShowDh(data.every((item) => item.level !== 'B' && item.level !== 'A'));
  124. setShowResult(true);
  125. }, 900);
  126. });
  127. };
  128. useEffect(() => {
  129. if (!visible || !tradeNo || dataLoadedRef.current) return;
  130. let attempts = 0;
  131. const maxAttempts = 13;
  132. let timeoutId: ReturnType<typeof setTimeout>;
  133. let isMounted = true;
  134. const fetchData = async () => {
  135. if (!isMounted) return;
  136. try {
  137. const res = await getApplyResult(tradeNo);
  138. if (!isMounted) return;
  139. if (res?.inventoryList && res.inventoryList.length > 0) {
  140. dataLoadedRef.current = true;
  141. if (res.rebateAmount) setRebateAmount(res.rebateAmount);
  142. let array = res.inventoryList;
  143. if (res.magicFireworksList && res.magicFireworksList.length > 0) {
  144. array = [...res.magicFireworksList, ...res.inventoryList];
  145. }
  146. flipAnims.current = array.map(() => new Animated.Value(0));
  147. const sum = array.reduce((acc: number, item: LotteryItem) => acc + (item.magicAmount || 0), 0);
  148. setTotal(sum);
  149. setTableData(array);
  150. setLoading(false);
  151. // 直接开始翻牌动画
  152. setTimeout(() => flipCards(array), 500);
  153. } else if (attempts < maxAttempts) {
  154. attempts++;
  155. timeoutId = setTimeout(fetchData, 400);
  156. } else {
  157. setLoading(false);
  158. window.alert('获取结果超时,请在仓库中查看');
  159. }
  160. } catch (error) {
  161. if (attempts < maxAttempts) {
  162. attempts++;
  163. timeoutId = setTimeout(fetchData, 400);
  164. } else {
  165. setLoading(false);
  166. }
  167. }
  168. };
  169. fetchData();
  170. return () => {
  171. isMounted = false;
  172. if (timeoutId) clearTimeout(timeoutId);
  173. };
  174. }, [visible, tradeNo]);
  175. const handleSkip = () => {
  176. setIsSkip(false);
  177. flipAnims.current.forEach((anim) => anim.setValue(1));
  178. setShowDh(tableData.every((item) => item.level !== 'B' && item.level !== 'A'));
  179. setShowResult(true);
  180. };
  181. const handleDhAll = async () => {
  182. if (!total) return;
  183. try {
  184. const ids = tableData.filter((item) => item.magicAmount).map((item) => item.id);
  185. const res = await convertApply(ids);
  186. if (res) {
  187. setTableData((prev) => prev.map((item) => ({ ...item, magicAmount: 0 })));
  188. setTotal(0);
  189. window.alert('兑换成功');
  190. }
  191. } catch {
  192. window.alert('兑换失败,请重试');
  193. }
  194. };
  195. const handleBack = () => {
  196. setVisible(false);
  197. onClose?.();
  198. };
  199. const handleGoStore = () => {
  200. setVisible(false);
  201. onGoStore?.();
  202. };
  203. const renderCard = (item: LotteryItem, index: number) => {
  204. const levelConfig = LEVEL_MAP[item.level] || LEVEL_MAP.D;
  205. const flipAnim = flipAnims.current[index] || new Animated.Value(1);
  206. const isFirst9 = index < 9;
  207. // 前9张卡片有翻转动画
  208. if (isFirst9) {
  209. const backRotate = flipAnim.interpolate({
  210. inputRange: [0, 1],
  211. outputRange: ['0deg', '90deg']
  212. });
  213. const frontRotate = flipAnim.interpolate({
  214. inputRange: [0, 1],
  215. outputRange: ['-90deg', '0deg']
  216. });
  217. const backOpacity = flipAnim.interpolate({
  218. inputRange: [0, 0.5, 1],
  219. outputRange: [1, 0, 0]
  220. });
  221. const frontOpacity = flipAnim.interpolate({
  222. inputRange: [0, 0.5, 1],
  223. outputRange: [0, 0, 1]
  224. });
  225. return (
  226. <View key={item.id || index} style={styles.cardWrapper}>
  227. {/* 背面 - 卡牌背面 */}
  228. <Animated.View style={[styles.cardBack, { transform: [{ rotateY: backRotate }], opacity: backOpacity }]}>
  229. <Image source={{ uri: LotteryImages.cardBack }} style={styles.cardBackImage} contentFit="cover" />
  230. </Animated.View>
  231. {/* 正面 - 商品信息 */}
  232. <Animated.View style={[styles.cardFront, { transform: [{ rotateY: frontRotate }], opacity: frontOpacity }]}>
  233. <ImageBackground source={{ uri: levelConfig.resultBg }} style={styles.cardFrontBg} resizeMode="cover">
  234. <Image source={{ uri: item.cover }} style={styles.productImage} contentFit="contain" />
  235. <Image source={{ uri: levelConfig.borderImg }} style={styles.borderImage} contentFit="cover" />
  236. <View style={styles.cardInfo}>
  237. <View style={styles.infoRow}>
  238. <Text style={styles.levelText}>{levelConfig.title}</Text>
  239. <Text style={styles.priceText}>¥{item.spu?.marketPrice || 0}</Text>
  240. </View>
  241. <View style={styles.exchangeRow}>
  242. <Text style={styles.exchangeText}>价值:{item.magicAmount || 0}果实</Text>
  243. </View>
  244. <Text style={styles.nameText} numberOfLines={1}>{item.name}</Text>
  245. </View>
  246. </ImageBackground>
  247. </Animated.View>
  248. </View>
  249. );
  250. }
  251. // 9张以后的卡片直接显示正面
  252. return (
  253. <View key={item.id || index} style={styles.cardWrapper}>
  254. <View style={styles.cardFrontStatic}>
  255. <ImageBackground source={{ uri: levelConfig.resultBg }} style={styles.cardFrontBg} resizeMode="cover">
  256. <Image source={{ uri: item.cover }} style={styles.productImage} contentFit="contain" />
  257. <Image source={{ uri: levelConfig.borderImg }} style={styles.borderImage} contentFit="cover" />
  258. <View style={styles.cardInfo}>
  259. <View style={styles.infoRow}>
  260. <Text style={styles.levelText}>{levelConfig.title}</Text>
  261. <Text style={styles.priceText}>¥{item.spu?.marketPrice || 0}</Text>
  262. </View>
  263. <View style={styles.exchangeRow}>
  264. <Text style={styles.exchangeText}>价值:{item.magicAmount || 0}果实</Text>
  265. </View>
  266. <Text style={styles.nameText} numberOfLines={1}>{item.name}</Text>
  267. </View>
  268. </ImageBackground>
  269. </View>
  270. </View>
  271. );
  272. };
  273. return (
  274. <Modal visible={visible} transparent animationType="fade" onRequestClose={handleBack}>
  275. <View style={styles.container}>
  276. <ImageBackground source={{ uri: LotteryImages.lotteryBg }} style={styles.background} resizeMode="cover">
  277. {/* 光晕效果 */}
  278. {haloShow && (
  279. <View style={styles.haloSection}>
  280. <Image source={{ uri: LotteryImages.halo }} style={styles.halo} contentFit="cover" />
  281. </View>
  282. )}
  283. {/* 标题 */}
  284. <View style={[styles.titleSection, { marginTop: insets.top + 40 }]}>
  285. <Text style={styles.titleText}>恭喜您获得</Text>
  286. </View>
  287. {/* 主内容区 */}
  288. <View style={styles.mainSection}>
  289. {loading ? (
  290. <View style={styles.loadingBox}>
  291. <ActivityIndicator size="large" color="#fff" />
  292. <Text style={styles.loadingText}>正在开启宝箱...</Text>
  293. </View>
  294. ) : (
  295. <ScrollView style={styles.cardList} showsVerticalScrollIndicator={false}>
  296. <View style={styles.cardGrid}>
  297. {tableData.map((item, index) => renderCard(item, index))}
  298. </View>
  299. </ScrollView>
  300. )}
  301. </View>
  302. {/* 底部按钮区 */}
  303. {showResult && (
  304. <View style={[styles.bottomSection, { paddingBottom: insets.bottom + 20 }]}>
  305. <View style={styles.bottomBtns}>
  306. {total > 0 && showDh && (
  307. <TouchableOpacity style={styles.dhBtn} onPress={handleDhAll}>
  308. <Text style={styles.dhBtnText}>全部兑换</Text>
  309. <Text style={styles.dhBtnSubText}>共兑换果实 {total}</Text>
  310. </TouchableOpacity>
  311. )}
  312. <TouchableOpacity style={styles.againBtn} onPress={handleBack}>
  313. <Text style={styles.againBtnText}>再来一发</Text>
  314. </TouchableOpacity>
  315. </View>
  316. <TouchableOpacity style={styles.storeLink} onPress={handleGoStore}>
  317. <Text style={styles.storeLinkText}>前往 <Text style={styles.storeHighlight}>仓库</Text> 查看</Text>
  318. </TouchableOpacity>
  319. {rebateAmount > 0 && (
  320. <Text style={styles.rebateText}>本次支付返还果实 <Text style={styles.rebateAmount}>{rebateAmount}</Text> 枚</Text>
  321. )}
  322. <View style={styles.animationSwitch}>
  323. <Text style={styles.switchLabel}>是否开启动画</Text>
  324. <TouchableOpacity
  325. style={[styles.switchBtn, animationEnabled && styles.switchBtnActive]}
  326. onPress={() => setAnimationEnabled(!animationEnabled)}
  327. >
  328. <View style={[styles.switchThumb, animationEnabled && styles.switchThumbActive]} />
  329. </TouchableOpacity>
  330. </View>
  331. </View>
  332. )}
  333. {/* 跳过动画按钮 */}
  334. {isSkip && !loading && (
  335. <TouchableOpacity style={styles.skipBtn} onPress={handleSkip}>
  336. <Text style={styles.skipText}>跳过动画</Text>
  337. </TouchableOpacity>
  338. )}
  339. </ImageBackground>
  340. </View>
  341. </Modal>
  342. );
  343. }
  344. );
  345. const styles = StyleSheet.create({
  346. container: { flex: 1, backgroundColor: '#1a1a2e' },
  347. background: { flex: 1 },
  348. haloSection: { position: 'absolute', left: 0, top: 0, right: 0, bottom: 0, zIndex: 9999 },
  349. halo: { width: '100%', height: '100%' },
  350. titleSection: { alignItems: 'center', marginBottom: 15 },
  351. titleText: {
  352. fontSize: 31,
  353. fontWeight: 'bold',
  354. color: '#fffecc',
  355. textShadowColor: '#a06939',
  356. textShadowOffset: { width: 1, height: 1 },
  357. textShadowRadius: 2
  358. },
  359. mainSection: { flex: 1, paddingTop: 20 },
  360. loadingBox: { flex: 1, justifyContent: 'center', alignItems: 'center' },
  361. loadingText: { marginTop: 15, fontSize: 14, color: '#fff' },
  362. cardList: { flex: 1 },
  363. cardGrid: {
  364. flexDirection: 'row',
  365. flexWrap: 'wrap',
  366. paddingHorizontal: 15,
  367. justifyContent: 'flex-start'
  368. },
  369. cardWrapper: {
  370. width: CARD_WIDTH,
  371. height: CARD_HEIGHT,
  372. marginHorizontal: 5,
  373. marginBottom: 15,
  374. position: 'relative'
  375. },
  376. cardBack: {
  377. position: 'absolute',
  378. width: '100%',
  379. height: '100%',
  380. backfaceVisibility: 'hidden'
  381. },
  382. cardBackImage: { width: '100%', height: '100%', borderRadius: 10 },
  383. cardFront: {
  384. position: 'absolute',
  385. width: '100%',
  386. height: '100%',
  387. backfaceVisibility: 'hidden',
  388. borderRadius: 10,
  389. overflow: 'hidden'
  390. },
  391. cardFrontStatic: {
  392. width: '100%',
  393. height: '100%',
  394. borderRadius: 10,
  395. overflow: 'hidden'
  396. },
  397. cardFrontBg: {
  398. width: '100%',
  399. height: '100%',
  400. paddingTop: 15,
  401. borderRadius: 10,
  402. overflow: 'hidden'
  403. },
  404. productImage: { width: '85%', height: '55%', alignSelf: 'center' },
  405. borderImage: { position: 'absolute', left: 0, top: 0, width: '100%', height: '100%' },
  406. cardInfo: { position: 'absolute', left: 0, right: 0, bottom: 7, paddingHorizontal: 10 },
  407. infoRow: {
  408. flexDirection: 'row',
  409. justifyContent: 'space-between',
  410. alignItems: 'center',
  411. marginBottom: 2
  412. },
  413. levelText: { fontSize: 13, fontWeight: 'bold', color: '#fff' },
  414. priceText: { fontSize: 12, fontWeight: 'bold', color: '#fff' },
  415. exchangeRow: { marginBottom: 2 },
  416. exchangeText: { fontSize: 10, color: '#fff', fontWeight: 'bold' },
  417. nameText: { fontSize: 12, fontWeight: 'bold', color: '#fff' },
  418. bottomSection: { paddingHorizontal: 20, paddingTop: 20 },
  419. bottomBtns: { flexDirection: 'row', justifyContent: 'center', alignItems: 'center' },
  420. dhBtn: {
  421. backgroundColor: '#fff7e3',
  422. borderRadius: 20,
  423. paddingHorizontal: 18,
  424. paddingVertical: 8,
  425. marginRight: 10,
  426. alignItems: 'center'
  427. },
  428. dhBtnText: { fontSize: 14, fontWeight: '500', color: '#000' },
  429. dhBtnSubText: { fontSize: 10, color: '#735200' },
  430. againBtn: {
  431. backgroundColor: '#fec433',
  432. borderRadius: 20,
  433. paddingHorizontal: 25,
  434. paddingVertical: 12
  435. },
  436. againBtnText: { fontSize: 14, fontWeight: '600', color: '#000' },
  437. storeLink: { alignItems: 'center', marginTop: 12 },
  438. storeLinkText: { fontSize: 12, color: '#fff' },
  439. storeHighlight: { color: '#ff9600' },
  440. rebateText: { textAlign: 'center', marginTop: 13, fontSize: 13, color: '#fff' },
  441. rebateAmount: { color: '#ffeb3b', fontSize: 14, fontWeight: 'bold' },
  442. animationSwitch: {
  443. flexDirection: 'row',
  444. justifyContent: 'center',
  445. alignItems: 'center',
  446. marginTop: 8
  447. },
  448. switchLabel: { fontSize: 12, color: '#dedede', marginRight: 10 },
  449. switchBtn: {
  450. width: 44,
  451. height: 24,
  452. borderRadius: 12,
  453. backgroundColor: '#666',
  454. justifyContent: 'center',
  455. paddingHorizontal: 2
  456. },
  457. switchBtnActive: { backgroundColor: '#ff9600' },
  458. switchThumb: { width: 20, height: 20, borderRadius: 10, backgroundColor: '#fff' },
  459. switchThumbActive: { alignSelf: 'flex-end' },
  460. skipBtn: {
  461. position: 'absolute',
  462. bottom: '10%',
  463. alignSelf: 'center',
  464. backgroundColor: 'rgba(0,0,0,0.4)',
  465. paddingHorizontal: 15,
  466. paddingVertical: 7,
  467. borderRadius: 15
  468. },
  469. skipText: { fontSize: 14, color: '#fff' },
  470. });