NumChooseModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  1. import React, { forwardRef, useCallback, useImperativeHandle, useState } from 'react';
  2. import {
  3. ActivityIndicator,
  4. Alert,
  5. Dimensions,
  6. ImageBackground,
  7. Modal,
  8. ScrollView,
  9. StyleSheet,
  10. Text,
  11. TouchableOpacity,
  12. View,
  13. } from 'react-native';
  14. import { Images } from '@/constants/images';
  15. import { getUnavailableSeatNumbers, previewOrder } from '@/services/award';
  16. const { width: SCREEN_WIDTH } = Dimensions.get('window');
  17. const ITEM_WIDTH = Math.floor((SCREEN_WIDTH - 28 - 12 * 4) / 5);
  18. // 等级配置
  19. const LEVEL_MAP: Record<string, { title: string; color: string }> = {
  20. A: { title: '超神款', color: '#FF4444' },
  21. B: { title: '欧皇款', color: '#FF9600' },
  22. C: { title: '隐藏款', color: '#9B59B6' },
  23. D: { title: '普通款', color: '#00CCFF' },
  24. };
  25. interface BoxData {
  26. number: string;
  27. quantity: number;
  28. lastNumber: number;
  29. }
  30. interface NumChooseModalProps {
  31. poolId: string;
  32. onPay: (params: { preview: any; seatNumbers: number[]; boxNumber: string }) => void;
  33. }
  34. export interface NumChooseModalRef {
  35. show: (box: BoxData) => void;
  36. close: () => void;
  37. }
  38. export const NumChooseModal = forwardRef<NumChooseModalRef, NumChooseModalProps>(
  39. ({ poolId, onPay }, ref) => {
  40. const [visible, setVisible] = useState(false);
  41. const [box, setBox] = useState<BoxData | null>(null);
  42. const [tabs, setTabs] = useState<{ title: string; value: number; data: number[] }[]>([]);
  43. const [currentTab, setCurrentTab] = useState<{ title: string; value: number; data: number[] } | null>(null);
  44. const [checkMap, setCheckMap] = useState<Record<number, number>>({});
  45. const [useMap, setUseMap] = useState<Record<number, string>>({});
  46. const [lockMap, setLockMap] = useState<Record<number, number>>({});
  47. const [loading, setLoading] = useState(false);
  48. // 已选择的号码列表
  49. const chooseData = Object.values(checkMap);
  50. // 初始化标签页
  51. const handleTab = useCallback((boxData: BoxData) => {
  52. const totalData: number[] = [];
  53. for (let i = 1; i <= boxData.quantity; i++) {
  54. totalData.push(i);
  55. }
  56. const newTabs: { title: string; value: number; data: number[] }[] = [];
  57. const count = Math.floor(boxData.quantity / 100) + (boxData.quantity % 100 > 0 ? 1 : 0);
  58. for (let i = 0; i < count; i++) {
  59. let title = `${100 * i + 1}~${100 * i + 100}`;
  60. if (100 * (i + 1) > totalData.length) {
  61. title = `${100 * i + 1}~${totalData.length}`;
  62. }
  63. newTabs.push({
  64. title,
  65. value: i + 1,
  66. data: totalData.slice(100 * i, 100 * (i + 1)),
  67. });
  68. }
  69. setTabs(newTabs);
  70. setCurrentTab(newTabs[0] || null);
  71. }, []);
  72. // 获取不可用座位号
  73. const getData = useCallback(async (boxData: BoxData, tab?: { data: number[] }) => {
  74. try {
  75. let startSeatNumber = 1;
  76. let endSeatNumber = 100;
  77. if (tab && tab.data.length > 0) {
  78. startSeatNumber = tab.data[0];
  79. endSeatNumber = tab.data[tab.data.length - 1];
  80. }
  81. const res = await getUnavailableSeatNumbers(poolId, boxData.number, startSeatNumber, endSeatNumber);
  82. if (res) {
  83. const nums: number[] = [];
  84. if (res.usedSeatNumbers) {
  85. const map: Record<number, string> = {};
  86. res.usedSeatNumbers.forEach((item: { seatNumber: number; level: string }) => {
  87. map[item.seatNumber] = item.level;
  88. nums.push(item.seatNumber);
  89. });
  90. setUseMap(map);
  91. }
  92. if (res.applyedSeatNumbers) {
  93. const map: Record<number, number> = {};
  94. res.applyedSeatNumbers.forEach((item: number) => {
  95. map[item] = item;
  96. nums.push(item);
  97. });
  98. setLockMap(map);
  99. }
  100. // 移除已被占用的选择
  101. if (nums.length > 0) {
  102. setCheckMap((prev) => {
  103. const newMap = { ...prev };
  104. nums.forEach((n) => delete newMap[n]);
  105. return newMap;
  106. });
  107. }
  108. }
  109. } catch (error) {
  110. console.error('获取座位号失败:', error);
  111. }
  112. }, [poolId]);
  113. useImperativeHandle(ref, () => ({
  114. show: (boxData: BoxData) => {
  115. setBox(boxData);
  116. setCheckMap({});
  117. setUseMap({});
  118. setLockMap({});
  119. setVisible(true);
  120. handleTab(boxData);
  121. // 延迟获取数据,等待标签页初始化完成
  122. setTimeout(() => {
  123. const totalData: number[] = [];
  124. for (let i = 1; i <= boxData.quantity; i++) {
  125. totalData.push(i);
  126. }
  127. const firstTabData = totalData.slice(0, 100);
  128. getData(boxData, { data: firstTabData });
  129. }, 100);
  130. },
  131. close: () => {
  132. setVisible(false);
  133. setBox(null);
  134. setTabs([]);
  135. setCurrentTab(null);
  136. setCheckMap({});
  137. setUseMap({});
  138. setLockMap({});
  139. },
  140. }));
  141. const close = () => {
  142. setVisible(false);
  143. };
  144. // 切换标签页
  145. const clickTab = (tab: { title: string; value: number; data: number[] }) => {
  146. setCurrentTab(tab);
  147. if (box) getData(box, tab);
  148. };
  149. // 选择/取消选择号码
  150. const choose = (item: number) => {
  151. if (useMap[item] || lockMap[item]) return;
  152. setCheckMap((prev) => {
  153. const newMap = { ...prev };
  154. if (newMap[item]) {
  155. delete newMap[item];
  156. } else {
  157. if (Object.keys(newMap).length >= 50) {
  158. Alert.alert('提示', '最多不超过50发');
  159. return prev;
  160. }
  161. newMap[item] = item;
  162. }
  163. return newMap;
  164. });
  165. };
  166. // 删除已选择的号码
  167. const deleteChoose = (item: number) => {
  168. setCheckMap((prev) => {
  169. const newMap = { ...prev };
  170. delete newMap[item];
  171. return newMap;
  172. });
  173. };
  174. // 预览订单
  175. const preview = async () => {
  176. if (!box) return;
  177. if (chooseData.length <= 0) {
  178. Alert.alert('提示', '请选择号码');
  179. return;
  180. }
  181. setLoading(true);
  182. try {
  183. const res = await previewOrder(poolId, chooseData.length, box.number, chooseData);
  184. if (res) {
  185. if (res.duplicateSeatNumbers && res.duplicateSeatNumbers.length > 0) {
  186. Alert.alert('提示', `${res.duplicateSeatNumbers.join(',')}号被占用`);
  187. // 移除被占用的号码
  188. setCheckMap((prev) => {
  189. const newMap = { ...prev };
  190. res.duplicateSeatNumbers.forEach((n: number) => delete newMap[n]);
  191. return newMap;
  192. });
  193. getData(box);
  194. return;
  195. }
  196. onPay({ preview: res, seatNumbers: chooseData, boxNumber: box.number });
  197. close();
  198. }
  199. } catch (error: any) {
  200. Alert.alert('提示', error?.message || '获取订单信息失败');
  201. } finally {
  202. setLoading(false);
  203. }
  204. };
  205. return (
  206. <Modal visible={visible} transparent animationType="slide" onRequestClose={close}>
  207. <View style={styles.overlay}>
  208. <TouchableOpacity style={styles.mask} activeOpacity={1} onPress={close} />
  209. <ImageBackground source={{ uri: Images.box.detail.recordBg }} style={styles.container} resizeMode="cover">
  210. {/* 标题 */}
  211. <View style={styles.titleSection}>
  212. <Text style={styles.title}>换盒</Text>
  213. <TouchableOpacity style={styles.closeBtn} onPress={close}>
  214. <Text style={styles.closeText}>×</Text>
  215. </TouchableOpacity>
  216. </View>
  217. {/* 标签页 */}
  218. {tabs.length > 1 && (
  219. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.tabsScroll}>
  220. <View style={styles.tabs}>
  221. {tabs.map((tab) => (
  222. <TouchableOpacity
  223. key={tab.value}
  224. style={[styles.tabItem, currentTab?.value === tab.value && styles.tabItemActive]}
  225. onPress={() => clickTab(tab)}
  226. >
  227. <Text style={[styles.tabText, currentTab?.value === tab.value && styles.tabTextActive]}>
  228. {tab.title}
  229. </Text>
  230. </TouchableOpacity>
  231. ))}
  232. </View>
  233. </ScrollView>
  234. )}
  235. {/* 号码网格 */}
  236. <ScrollView style={styles.gridScroll} showsVerticalScrollIndicator={false}>
  237. <View style={styles.grid}>
  238. {currentTab?.data.map((item) => {
  239. const isChecked = !!checkMap[item];
  240. const isUsed = !!useMap[item];
  241. const isLocked = !!lockMap[item];
  242. return (
  243. <TouchableOpacity
  244. key={item}
  245. style={[
  246. styles.gridItem,
  247. isChecked && styles.gridItemActive,
  248. isUsed && styles.gridItemUsed,
  249. isLocked && styles.gridItemLocked,
  250. ]}
  251. onPress={() => choose(item)}
  252. disabled={isUsed || isLocked}
  253. >
  254. {isUsed ? (
  255. <View style={styles.usedContent}>
  256. <Text style={styles.usedNum}>{item}号</Text>
  257. <Text style={[styles.levelTitle, { color: LEVEL_MAP[useMap[item]]?.color }]}>
  258. {LEVEL_MAP[useMap[item]]?.title}
  259. </Text>
  260. </View>
  261. ) : (
  262. <Text style={[styles.gridItemText, isLocked && styles.gridItemTextLocked]}>
  263. {item}号
  264. </Text>
  265. )}
  266. {isChecked && (
  267. <View style={styles.checkIcon}>
  268. <Text style={styles.checkIconText}>✓</Text>
  269. </View>
  270. )}
  271. {isLocked && (
  272. <View style={styles.lockIcon}>
  273. <Text style={styles.lockIconText}>🔒</Text>
  274. </View>
  275. )}
  276. </TouchableOpacity>
  277. );
  278. })}
  279. </View>
  280. </ScrollView>
  281. {/* 已选择的号码 */}
  282. <View style={styles.selectedSection}>
  283. <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.selectedScroll}>
  284. {chooseData.map((item) => (
  285. <View key={item} style={styles.selectedItem}>
  286. <Text style={styles.selectedText}>{item}号</Text>
  287. <TouchableOpacity style={styles.selectedClose} onPress={() => deleteChoose(item)}>
  288. <Text style={styles.selectedCloseText}>×</Text>
  289. </TouchableOpacity>
  290. </View>
  291. ))}
  292. </ScrollView>
  293. </View>
  294. {/* 确认按钮 */}
  295. <View style={styles.btnBox}>
  296. <TouchableOpacity
  297. style={[styles.submitBtn, loading && styles.submitBtnDisabled]}
  298. onPress={preview}
  299. disabled={loading}
  300. >
  301. {loading ? (
  302. <ActivityIndicator color="#fff" size="small" />
  303. ) : (
  304. <>
  305. <Text style={styles.submitText}>确定选择</Text>
  306. <Text style={styles.submitSubText}>已选择({chooseData.length})发</Text>
  307. </>
  308. )}
  309. </TouchableOpacity>
  310. </View>
  311. </ImageBackground>
  312. </View>
  313. </Modal>
  314. );
  315. }
  316. );
  317. const styles = StyleSheet.create({
  318. overlay: {
  319. flex: 1,
  320. backgroundColor: 'rgba(0,0,0,0.5)',
  321. justifyContent: 'flex-end',
  322. },
  323. mask: { flex: 1 },
  324. container: {
  325. borderTopLeftRadius: 15,
  326. borderTopRightRadius: 15,
  327. paddingTop: 15,
  328. paddingBottom: 34,
  329. maxHeight: '80%',
  330. },
  331. titleSection: {
  332. alignItems: 'center',
  333. paddingVertical: 15,
  334. position: 'relative',
  335. },
  336. title: {
  337. fontSize: 16,
  338. fontWeight: 'bold',
  339. color: '#fff',
  340. textShadowColor: '#000',
  341. textShadowOffset: { width: 1, height: 1 },
  342. textShadowRadius: 2,
  343. },
  344. closeBtn: {
  345. position: 'absolute',
  346. right: 15,
  347. top: 10,
  348. width: 24,
  349. height: 24,
  350. backgroundColor: '#ebebeb',
  351. borderRadius: 12,
  352. justifyContent: 'center',
  353. alignItems: 'center',
  354. },
  355. closeText: { fontSize: 18, color: '#a2a2a2', marginTop: -2 },
  356. tabsScroll: {
  357. maxHeight: 40,
  358. marginHorizontal: 10,
  359. marginBottom: 10,
  360. },
  361. tabs: {
  362. flexDirection: 'row',
  363. },
  364. tabItem: {
  365. paddingHorizontal: 12,
  366. paddingVertical: 6,
  367. backgroundColor: 'rgba(255,255,255,0.2)',
  368. borderRadius: 15,
  369. marginRight: 8,
  370. },
  371. tabItemActive: {
  372. backgroundColor: '#FFC900',
  373. },
  374. tabText: {
  375. fontSize: 12,
  376. color: '#fff',
  377. },
  378. tabTextActive: {
  379. color: '#000',
  380. fontWeight: 'bold',
  381. },
  382. gridScroll: {
  383. height: 350,
  384. backgroundColor: '#fff',
  385. marginHorizontal: 10,
  386. borderRadius: 10,
  387. },
  388. grid: {
  389. flexDirection: 'row',
  390. flexWrap: 'wrap',
  391. padding: 10,
  392. },
  393. gridItem: {
  394. width: ITEM_WIDTH,
  395. height: ITEM_WIDTH / 2,
  396. backgroundColor: '#FFC900',
  397. borderWidth: 3,
  398. borderColor: '#000',
  399. borderRadius: 4,
  400. justifyContent: 'center',
  401. alignItems: 'center',
  402. margin: 4,
  403. position: 'relative',
  404. },
  405. gridItemActive: {},
  406. gridItemUsed: {
  407. backgroundColor: '#e8e8e8',
  408. },
  409. gridItemLocked: {
  410. backgroundColor: 'rgba(98, 99, 115, 0.3)',
  411. borderWidth: 0,
  412. },
  413. gridItemText: {
  414. fontSize: 12,
  415. color: '#000',
  416. fontWeight: 'bold',
  417. },
  418. gridItemTextLocked: {
  419. color: 'rgba(255,255,255,0.3)',
  420. },
  421. usedContent: {
  422. alignItems: 'center',
  423. },
  424. usedNum: {
  425. fontSize: 10,
  426. color: '#000',
  427. opacity: 0.5,
  428. },
  429. levelTitle: {
  430. fontSize: 10,
  431. fontWeight: 'bold',
  432. },
  433. checkIcon: {
  434. position: 'absolute',
  435. right: 0,
  436. bottom: 0,
  437. width: 16,
  438. height: 14,
  439. backgroundColor: '#000',
  440. borderTopLeftRadius: 6,
  441. justifyContent: 'center',
  442. alignItems: 'center',
  443. },
  444. checkIconText: {
  445. fontSize: 10,
  446. color: '#FFC900',
  447. },
  448. lockIcon: {
  449. position: 'absolute',
  450. right: 0,
  451. bottom: 0,
  452. width: 16,
  453. height: 14,
  454. justifyContent: 'center',
  455. alignItems: 'center',
  456. },
  457. lockIconText: {
  458. fontSize: 10,
  459. },
  460. selectedSection: {
  461. height: 40,
  462. marginHorizontal: 10,
  463. marginTop: 10,
  464. },
  465. selectedScroll: {
  466. flex: 1,
  467. },
  468. selectedItem: {
  469. flexDirection: 'row',
  470. alignItems: 'center',
  471. backgroundColor: '#FFC900',
  472. borderWidth: 3,
  473. borderColor: '#000',
  474. borderRadius: 4,
  475. paddingHorizontal: 10,
  476. paddingVertical: 4,
  477. marginRight: 8,
  478. height: 32,
  479. },
  480. selectedText: {
  481. fontSize: 12,
  482. color: '#000',
  483. fontWeight: 'bold',
  484. },
  485. selectedClose: {
  486. marginLeft: 6,
  487. width: 16,
  488. height: 16,
  489. backgroundColor: 'rgba(255,255,255,0.3)',
  490. borderRadius: 8,
  491. justifyContent: 'center',
  492. alignItems: 'center',
  493. },
  494. selectedCloseText: {
  495. fontSize: 12,
  496. color: '#000',
  497. },
  498. btnBox: {
  499. paddingHorizontal: 20,
  500. paddingTop: 15,
  501. },
  502. submitBtn: {
  503. backgroundColor: '#ff9600',
  504. height: 50,
  505. borderRadius: 25,
  506. justifyContent: 'center',
  507. alignItems: 'center',
  508. },
  509. submitBtnDisabled: {
  510. opacity: 0.6,
  511. },
  512. submitText: {
  513. fontSize: 16,
  514. fontWeight: 'bold',
  515. color: '#fff',
  516. textShadowColor: '#000',
  517. textShadowOffset: { width: 1, height: 1 },
  518. textShadowRadius: 1,
  519. },
  520. submitSubText: {
  521. fontSize: 10,
  522. color: '#fff',
  523. marginTop: 2,
  524. },
  525. });