NumSelectionModal.tsx 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. import { LEVEL_MAP } from '@/constants/config';
  2. import { Images } from '@/constants/images';
  3. import { getUnavailableSeatNumbers, previewOrder } from '@/services/award';
  4. import { Image, ImageBackground } from 'expo-image';
  5. import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
  6. import {
  7. ActivityIndicator,
  8. Alert,
  9. Dimensions,
  10. Modal,
  11. ScrollView,
  12. StyleSheet,
  13. Text,
  14. TouchableOpacity,
  15. View
  16. } from 'react-native';
  17. const { width: SCREEN_WIDTH } = Dimensions.get('window');
  18. interface NumSelectionModalProps {
  19. poolId: string;
  20. onPay: (params: { previewRes: any, chooseNum: number[], boxNum: string }) => void;
  21. }
  22. export interface NumSelectionModalRef {
  23. show: (box: any) => void;
  24. close: () => void;
  25. }
  26. export const NumSelectionModal = forwardRef<NumSelectionModalRef, NumSelectionModalProps>(
  27. ({ poolId, onPay }, ref) => {
  28. const [visible, setVisible] = useState(false);
  29. const [box, setBox] = useState<any>(null);
  30. const [tabs, setTabs] = useState<any[]>([]);
  31. const [activeTab, setActiveTab] = useState<any>(null);
  32. // Status Maps
  33. const [useMap, setUseMap] = useState<Record<number, string>>({}); // Sold/Opened (seatNum -> level)
  34. const [lockMap, setLockMap] = useState<Record<number, number>>({}); // Locked/Pending (seatNum -> seatNum)
  35. const [checkMap, setCheckMap] = useState<Record<number, number>>({}); // Selected by user (seatNum -> seatNum)
  36. const [loading, setLoading] = useState(false);
  37. useImperativeHandle(ref, () => ({
  38. show: (boxItem: any) => {
  39. setBox(boxItem);
  40. setVisible(true);
  41. setCheckMap({});
  42. // Initialize Tabs based on box quantity
  43. // Assuming max 100 per tab like legacy
  44. const total = boxItem.quantity || 100; // Default or from box
  45. const newTabs = [];
  46. const itemsPerTab = 100;
  47. const count = Math.ceil(total / itemsPerTab);
  48. for (let i = 0; i < count; i++) {
  49. const start = i * itemsPerTab + 1;
  50. const end = Math.min((i + 1) * itemsPerTab, total);
  51. const data = Array.from({ length: end - start + 1 }, (_, k) => k + start);
  52. newTabs.push({
  53. title: `${start}~${end}`,
  54. value: i,
  55. data
  56. });
  57. }
  58. setTabs(newTabs);
  59. if (newTabs.length > 0) {
  60. setActiveTab(newTabs[0]);
  61. loadData(newTabs[0], boxItem.number);
  62. }
  63. },
  64. close: () => {
  65. setVisible(false);
  66. },
  67. }));
  68. const loadData = async (tab: any, boxNum: string) => {
  69. if (!tab || !boxNum) return;
  70. try {
  71. const start = tab.data[0];
  72. const end = tab.data[tab.data.length - 1];
  73. const res = await getUnavailableSeatNumbers(poolId, boxNum, start, end);
  74. if (res) {
  75. // Determine used (sold) and locked seats
  76. const newUseMap: Record<number, string> = { ...useMap }; // Merge? Or reset per tab? Legacy merges logic via object assignment but here standard React state update
  77. // Legacy clears map if not careful? No, it sets properties.
  78. // Let's reset for the current range to avoid stale data visually if we revisit, but merging is safer for 'global' knowledge.
  79. // Efficiency-wise, let's keep it simple.
  80. if (res.usedSeatNumbers) {
  81. res.usedSeatNumbers.forEach((item: any) => {
  82. newUseMap[item.seatNumber] = item.level;
  83. });
  84. }
  85. const newLockMap: Record<number, number> = { ...lockMap };
  86. if (res.applyedSeatNumbers) {
  87. res.applyedSeatNumbers.forEach((num: number) => {
  88. newLockMap[num] = num;
  89. });
  90. }
  91. setUseMap(newUseMap);
  92. setLockMap(newLockMap);
  93. // Remove selected if they became unavailable
  94. const newCheckMap = { ...checkMap };
  95. let changed = false;
  96. Object.keys(newCheckMap).forEach(key => {
  97. const num = Number(key);
  98. if (newUseMap[num] || newLockMap[num]) {
  99. delete newCheckMap[num];
  100. changed = true;
  101. }
  102. });
  103. if (changed) setCheckMap(newCheckMap);
  104. }
  105. } catch (error) {
  106. console.error('Failed to load seat info', error);
  107. }
  108. };
  109. useEffect(() => {
  110. if (activeTab && box) {
  111. loadData(activeTab, box.number);
  112. }
  113. }, [activeTab]);
  114. const handleTabChange = (tab: any) => {
  115. setActiveTab(tab);
  116. };
  117. const toggleSelect = (num: number) => {
  118. if (useMap[num] || lockMap[num]) return;
  119. setCheckMap(prev => {
  120. const next = { ...prev };
  121. if (next[num]) {
  122. delete next[num];
  123. } else {
  124. if (Object.keys(next).length >= 50) {
  125. Alert.alert('提示', '最多不超过50发');
  126. return prev;
  127. }
  128. next[num] = num;
  129. }
  130. return next;
  131. });
  132. };
  133. const handleConfirm = async () => {
  134. const selectedNums = Object.values(checkMap).map(Number);
  135. if (selectedNums.length === 0) {
  136. Alert.alert('提示', '请选择号码');
  137. return;
  138. }
  139. setLoading(true);
  140. try {
  141. const res = await previewOrder(poolId, selectedNums.length, box.number, selectedNums);
  142. if (res) {
  143. if (res.duplicateSeatNumbers && res.duplicateSeatNumbers.length > 0) {
  144. Alert.alert('提示', `${res.duplicateSeatNumbers.join(',')}号被占用`);
  145. // Remove duplicates
  146. const newCheckMap = { ...checkMap };
  147. res.duplicateSeatNumbers.forEach((n: number) => delete newCheckMap[n]);
  148. setCheckMap(newCheckMap);
  149. // Refresh data
  150. loadData(activeTab, box.number);
  151. return;
  152. }
  153. // Proceed to pay callback
  154. onPay({ previewRes: res, chooseNum: selectedNums, boxNum: box.number });
  155. setVisible(false);
  156. }
  157. } catch (error: any) {
  158. Alert.alert('错误', error?.message || '请求失败');
  159. } finally {
  160. setLoading(false);
  161. }
  162. };
  163. const renderItem = (num: number) => {
  164. const isUsed = !!useMap[num];
  165. const isLocked = !!lockMap[num];
  166. const isSelected = !!checkMap[num];
  167. let content = <Text style={[styles.itemText, (isUsed || isLocked) && styles.disabledText]}>{num}号</Text>;
  168. let style = [styles.item];
  169. if (isUsed) {
  170. style.push(styles.levelItem as any);
  171. const level = useMap[num];
  172. content = (
  173. <View style={styles.usedContent}>
  174. <Text style={styles.usedNum}>{num}号</Text>
  175. <Text style={[styles.levelText, { color: LEVEL_MAP[level]?.color }]}>
  176. {LEVEL_MAP[level]?.title || level}
  177. </Text>
  178. </View>
  179. );
  180. } else if (isLocked) {
  181. style.push(styles.lockedItem as any);
  182. content = (
  183. <>
  184. <Text style={styles.disabledText}>{num}号</Text>
  185. <View style={styles.lockIcon}>
  186. {/* Icon placeholder or text */}
  187. <Text style={{fontSize: 10, color: '#fff'}}>🔒</Text>
  188. </View>
  189. </>
  190. );
  191. } else if (isSelected) {
  192. style.push(styles.selectedItem as any);
  193. content = (
  194. <>
  195. <Text style={styles.itemText}>{num}号</Text>
  196. <View style={styles.checkIcon}>
  197. <Text style={{fontSize: 8, color: '#F1423D'}}>✓</Text>
  198. </View>
  199. </>
  200. );
  201. }
  202. return (
  203. <TouchableOpacity
  204. key={num}
  205. style={style}
  206. onPress={() => toggleSelect(num)}
  207. disabled={isUsed || isLocked}
  208. >
  209. {content}
  210. </TouchableOpacity>
  211. );
  212. };
  213. const selectedList = Object.values(checkMap);
  214. return (
  215. <Modal visible={visible} transparent animationType="slide" onRequestClose={() => setVisible(false)}>
  216. <View style={styles.overlay}>
  217. <TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => setVisible(false)} />
  218. <ImageBackground
  219. source={{ uri: Images.box.detail.recordBg }}
  220. style={styles.container}
  221. resizeMode="stretch"
  222. >
  223. {/* Header */}
  224. <View style={styles.header}>
  225. <Image source={{ uri: Images.box.detail.recordTitleLeft }} style={styles.titleDecor} contentFit="contain" />
  226. <Text style={styles.title}>换盒</Text>
  227. <Image source={{ uri: Images.box.detail.recordTitleRight }} style={styles.titleDecor} contentFit="contain" />
  228. <TouchableOpacity style={styles.closeBtn} onPress={() => setVisible(false)}>
  229. <Text style={styles.closeText}>×</Text>
  230. </TouchableOpacity>
  231. </View>
  232. {/* Tabs for Ranges */}
  233. {tabs.length > 1 && (
  234. <View style={styles.tabs}>
  235. {tabs.map(tab => (
  236. <TouchableOpacity
  237. key={tab.title}
  238. style={[styles.tab, activeTab?.value === tab.value && styles.activeTab]}
  239. onPress={() => handleTabChange(tab)}
  240. >
  241. <Text style={[styles.tabText, activeTab?.value === tab.value && styles.activeTabText]}>
  242. {tab.title}
  243. </Text>
  244. </TouchableOpacity>
  245. ))}
  246. </View>
  247. )}
  248. {/* Grid */}
  249. <ScrollView contentContainerStyle={styles.grid}>
  250. {activeTab?.data.map((num: number) => renderItem(num))}
  251. </ScrollView>
  252. {/* Bottom Selection Bar */}
  253. <View style={styles.bottomSection}>
  254. <ScrollView horizontal style={styles.selectedScroll} contentContainerStyle={styles.selectedContent}>
  255. {selectedList.map((num) => (
  256. <View key={num} style={styles.selectedChip}>
  257. <Text style={styles.selectedChipText}>{num}号</Text>
  258. <TouchableOpacity onPress={() => toggleSelect(Number(num))} style={styles.chipClose}>
  259. <Text style={styles.chipCloseText}>×</Text>
  260. </TouchableOpacity>
  261. </View>
  262. ))}
  263. </ScrollView>
  264. <TouchableOpacity
  265. style={styles.confirmBtn}
  266. onPress={handleConfirm}
  267. disabled={loading}
  268. >
  269. <ImageBackground source={{ uri: Images.common.loginBg }} style={styles.confirmBtnBg} resizeMode="stretch">
  270. {loading ? <ActivityIndicator color="#fff" /> : (
  271. <View style={{ alignItems: 'center' }}>
  272. <Text style={styles.confirmText}>确定选择</Text>
  273. <Text style={styles.subConfirmText}>已选择({selectedList.length})发</Text>
  274. </View>
  275. )}
  276. </ImageBackground>
  277. </TouchableOpacity>
  278. </View>
  279. </ImageBackground>
  280. </View>
  281. </Modal>
  282. );
  283. }
  284. );
  285. const styles = StyleSheet.create({
  286. overlay: {
  287. flex: 1,
  288. backgroundColor: 'rgba(0,0,0,0.5)',
  289. justifyContent: 'flex-end',
  290. },
  291. mask: { flex: 1 },
  292. container: {
  293. height: 600,
  294. paddingTop: 15,
  295. borderTopLeftRadius: 15,
  296. borderTopRightRadius: 15,
  297. overflow: 'hidden',
  298. backgroundColor: '#fff',
  299. },
  300. header: {
  301. flexDirection: 'row',
  302. alignItems: 'center',
  303. justifyContent: 'center',
  304. marginBottom: 15,
  305. position: 'relative',
  306. height: 44,
  307. },
  308. title: {
  309. fontSize: 18,
  310. fontWeight: 'bold',
  311. color: '#fff',
  312. marginHorizontal: 10,
  313. textShadowColor: '#000',
  314. textShadowOffset: { width: 1, height: 1 },
  315. textShadowRadius: 1,
  316. },
  317. titleDecor: {
  318. width: 17,
  319. height: 19,
  320. },
  321. closeBtn: {
  322. position: 'absolute',
  323. right: 15,
  324. top: 0,
  325. width: 24,
  326. height: 24,
  327. backgroundColor: '#ebebeb',
  328. borderRadius: 12,
  329. justifyContent: 'center',
  330. alignItems: 'center',
  331. },
  332. closeText: { fontSize: 18, color: '#a2a2a2', marginTop: -2 },
  333. tabs: {
  334. flexDirection: 'row',
  335. flexWrap: 'wrap',
  336. paddingHorizontal: 10,
  337. marginBottom: 10,
  338. },
  339. tab: {
  340. paddingVertical: 5,
  341. paddingHorizontal: 10,
  342. backgroundColor: '#f5f5f5',
  343. borderRadius: 4,
  344. marginRight: 8,
  345. marginBottom: 5,
  346. },
  347. activeTab: { backgroundColor: '#ffdb4d' },
  348. tabText: { fontSize: 12, color: '#666' },
  349. activeTabText: { color: '#000', fontWeight: 'bold' },
  350. grid: {
  351. flexDirection: 'row',
  352. flexWrap: 'wrap',
  353. paddingHorizontal: 10,
  354. paddingBottom: 20,
  355. },
  356. item: {
  357. width: (SCREEN_WIDTH - 20 - 45) / 5, // Approx 5 items per row
  358. height: ((SCREEN_WIDTH - 20 - 45) / 5) * 0.5,
  359. margin: 4,
  360. backgroundColor: '#FFC900',
  361. borderWidth: 3,
  362. borderColor: '#000',
  363. justifyContent: 'center',
  364. alignItems: 'center',
  365. borderRadius: 0,
  366. },
  367. itemText: { fontSize: 12, fontWeight: 'bold', color: '#000' },
  368. disabledText: { color: 'rgba(255,255,255,0.3)' },
  369. levelItem: { backgroundColor: '#e8e8e8', borderColor: '#e8e8e8' },
  370. lockedItem: { backgroundColor: 'rgba(98, 99, 115, 0.3)', borderColor: 'transparent', borderWidth: 0 },
  371. selectedItem: { backgroundColor: '#FFC900', borderColor: '#000' }, // Same as default but with icon
  372. usedContent: { alignItems: 'center' },
  373. usedNum: { fontSize: 10, opacity: 0.5 },
  374. levelText: { fontSize: 10, fontWeight: 'bold' },
  375. lockIcon: { position: 'absolute', bottom: 0, right: 0, backgroundColor: '#000', padding: 2, borderTopLeftRadius: 5 },
  376. checkIcon: {
  377. position: 'absolute',
  378. bottom: 0,
  379. right: 0,
  380. backgroundColor: '#fff',
  381. paddingHorizontal: 4,
  382. borderTopLeftRadius: 5,
  383. },
  384. bottomSection: {
  385. paddingBottom: 30,
  386. paddingTop: 10,
  387. borderTopWidth: 1,
  388. borderColor: '#eee',
  389. backgroundColor: '#fff',
  390. },
  391. selectedScroll: { maxHeight: 50, marginBottom: 10 },
  392. selectedContent: { paddingHorizontal: 10 },
  393. selectedChip: {
  394. flexDirection: 'row',
  395. alignItems: 'center',
  396. backgroundColor: '#FFC900',
  397. borderWidth: 2,
  398. borderColor: '#000',
  399. paddingHorizontal: 8,
  400. paddingVertical: 4,
  401. borderRadius: 4,
  402. marginRight: 8,
  403. height: 30,
  404. },
  405. selectedChipText: { fontSize: 12, fontWeight: 'bold', marginRight: 5 },
  406. chipClose: { backgroundColor: 'rgba(255,255,255,0.5)', borderRadius: 10, width: 16, height: 16, alignItems: 'center', justifyContent: 'center' },
  407. chipCloseText: { fontSize: 12, lineHeight: 14 },
  408. confirmBtn: {
  409. alignSelf: 'center',
  410. width: 200,
  411. height: 50,
  412. },
  413. confirmBtnBg: {
  414. flex: 1,
  415. justifyContent: 'center',
  416. alignItems: 'center',
  417. },
  418. confirmText: {
  419. color: '#fff',
  420. fontSize: 16,
  421. fontWeight: 'bold',
  422. textShadowColor: '#000',
  423. textShadowOffset: { width: 1, height: 1 },
  424. textShadowRadius: 1,
  425. },
  426. subConfirmText: {
  427. color: '#fff',
  428. fontSize: 10,
  429. marginTop: -2,
  430. },
  431. });