| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472 |
- import { LEVEL_MAP } from '@/constants/config';
- import { Images } from '@/constants/images';
- import { getUnavailableSeatNumbers, previewOrder } from '@/services/award';
- import { Image, ImageBackground } from 'expo-image';
- import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
- import {
- ActivityIndicator,
- Alert,
- Dimensions,
- Modal,
- ScrollView,
- StyleSheet,
- Text,
- TouchableOpacity,
- View
- } from 'react-native';
- const { width: SCREEN_WIDTH } = Dimensions.get('window');
- interface NumSelectionModalProps {
- poolId: string;
- onPay: (params: { previewRes: any, chooseNum: number[], boxNum: string }) => void;
- }
- export interface NumSelectionModalRef {
- show: (box: any) => void;
- close: () => void;
- }
- export const NumSelectionModal = forwardRef<NumSelectionModalRef, NumSelectionModalProps>(
- ({ poolId, onPay }, ref) => {
- const [visible, setVisible] = useState(false);
- const [box, setBox] = useState<any>(null);
- const [tabs, setTabs] = useState<any[]>([]);
- const [activeTab, setActiveTab] = useState<any>(null);
-
- // Status Maps
- const [useMap, setUseMap] = useState<Record<number, string>>({}); // Sold/Opened (seatNum -> level)
- const [lockMap, setLockMap] = useState<Record<number, number>>({}); // Locked/Pending (seatNum -> seatNum)
- const [checkMap, setCheckMap] = useState<Record<number, number>>({}); // Selected by user (seatNum -> seatNum)
- const [loading, setLoading] = useState(false);
- useImperativeHandle(ref, () => ({
- show: (boxItem: any) => {
- setBox(boxItem);
- setVisible(true);
- setCheckMap({});
- // Initialize Tabs based on box quantity
- // Assuming max 100 per tab like legacy
- const total = boxItem.quantity || 100; // Default or from box
- const newTabs = [];
- const itemsPerTab = 100;
- const count = Math.ceil(total / itemsPerTab);
-
- for (let i = 0; i < count; i++) {
- const start = i * itemsPerTab + 1;
- const end = Math.min((i + 1) * itemsPerTab, total);
- const data = Array.from({ length: end - start + 1 }, (_, k) => k + start);
- newTabs.push({
- title: `${start}~${end}`,
- value: i,
- data
- });
- }
- setTabs(newTabs);
- if (newTabs.length > 0) {
- setActiveTab(newTabs[0]);
- loadData(newTabs[0], boxItem.number);
- }
- },
- close: () => {
- setVisible(false);
- },
- }));
- const loadData = async (tab: any, boxNum: string) => {
- if (!tab || !boxNum) return;
-
- try {
- const start = tab.data[0];
- const end = tab.data[tab.data.length - 1];
- const res = await getUnavailableSeatNumbers(poolId, boxNum, start, end);
-
- if (res) {
- // Determine used (sold) and locked seats
- const newUseMap: Record<number, string> = { ...useMap }; // Merge? Or reset per tab? Legacy merges logic via object assignment but here standard React state update
- // Legacy clears map if not careful? No, it sets properties.
- // Let's reset for the current range to avoid stale data visually if we revisit, but merging is safer for 'global' knowledge.
- // Efficiency-wise, let's keep it simple.
-
- if (res.usedSeatNumbers) {
- res.usedSeatNumbers.forEach((item: any) => {
- newUseMap[item.seatNumber] = item.level;
- });
- }
-
- const newLockMap: Record<number, number> = { ...lockMap };
- if (res.applyedSeatNumbers) {
- res.applyedSeatNumbers.forEach((num: number) => {
- newLockMap[num] = num;
- });
- }
-
- setUseMap(newUseMap);
- setLockMap(newLockMap);
- // Remove selected if they became unavailable
- const newCheckMap = { ...checkMap };
- let changed = false;
- Object.keys(newCheckMap).forEach(key => {
- const num = Number(key);
- if (newUseMap[num] || newLockMap[num]) {
- delete newCheckMap[num];
- changed = true;
- }
- });
- if (changed) setCheckMap(newCheckMap);
- }
- } catch (error) {
- console.error('Failed to load seat info', error);
- }
- };
- useEffect(() => {
- if (activeTab && box) {
- loadData(activeTab, box.number);
- }
- }, [activeTab]);
- const handleTabChange = (tab: any) => {
- setActiveTab(tab);
- };
- const toggleSelect = (num: number) => {
- if (useMap[num] || lockMap[num]) return;
- setCheckMap(prev => {
- const next = { ...prev };
- if (next[num]) {
- delete next[num];
- } else {
- if (Object.keys(next).length >= 50) {
- Alert.alert('提示', '最多不超过50发');
- return prev;
- }
- next[num] = num;
- }
- return next;
- });
- };
- const handleConfirm = async () => {
- const selectedNums = Object.values(checkMap).map(Number);
- if (selectedNums.length === 0) {
- Alert.alert('提示', '请选择号码');
- return;
- }
- setLoading(true);
- try {
- const res = await previewOrder(poolId, selectedNums.length, box.number, selectedNums);
- if (res) {
- if (res.duplicateSeatNumbers && res.duplicateSeatNumbers.length > 0) {
- Alert.alert('提示', `${res.duplicateSeatNumbers.join(',')}号被占用`);
- // Remove duplicates
- const newCheckMap = { ...checkMap };
- res.duplicateSeatNumbers.forEach((n: number) => delete newCheckMap[n]);
- setCheckMap(newCheckMap);
- // Refresh data
- loadData(activeTab, box.number);
- return;
- }
-
- // Proceed to pay callback
- onPay({ previewRes: res, chooseNum: selectedNums, boxNum: box.number });
- setVisible(false);
- }
- } catch (error: any) {
- Alert.alert('错误', error?.message || '请求失败');
- } finally {
- setLoading(false);
- }
- };
- const renderItem = (num: number) => {
- const isUsed = !!useMap[num];
- const isLocked = !!lockMap[num];
- const isSelected = !!checkMap[num];
-
- let content = <Text style={[styles.itemText, (isUsed || isLocked) && styles.disabledText]}>{num}号</Text>;
- let style = [styles.item];
- if (isUsed) {
- style.push(styles.levelItem as any);
- const level = useMap[num];
- content = (
- <View style={styles.usedContent}>
- <Text style={styles.usedNum}>{num}号</Text>
- <Text style={[styles.levelText, { color: LEVEL_MAP[level]?.color }]}>
- {LEVEL_MAP[level]?.title || level}
- </Text>
- </View>
- );
- } else if (isLocked) {
- style.push(styles.lockedItem as any);
- content = (
- <>
- <Text style={styles.disabledText}>{num}号</Text>
- <View style={styles.lockIcon}>
- {/* Icon placeholder or text */}
- <Text style={{fontSize: 10, color: '#fff'}}>🔒</Text>
- </View>
- </>
- );
- } else if (isSelected) {
- style.push(styles.selectedItem as any);
- content = (
- <>
- <Text style={styles.itemText}>{num}号</Text>
- <View style={styles.checkIcon}>
- <Text style={{fontSize: 8, color: '#F1423D'}}>✓</Text>
- </View>
- </>
- );
- }
- return (
- <TouchableOpacity
- key={num}
- style={style}
- onPress={() => toggleSelect(num)}
- disabled={isUsed || isLocked}
- >
- {content}
- </TouchableOpacity>
- );
- };
- const selectedList = Object.values(checkMap);
- return (
- <Modal visible={visible} transparent animationType="slide" onRequestClose={() => setVisible(false)}>
- <View style={styles.overlay}>
- <TouchableOpacity style={styles.mask} activeOpacity={1} onPress={() => setVisible(false)} />
-
- <ImageBackground
- source={{ uri: Images.box.detail.recordBg }}
- style={styles.container}
- resizeMode="stretch"
- >
- {/* Header */}
- <View style={styles.header}>
- <Image source={{ uri: Images.box.detail.recordTitleLeft }} style={styles.titleDecor} contentFit="contain" />
- <Text style={styles.title}>换盒</Text>
- <Image source={{ uri: Images.box.detail.recordTitleRight }} style={styles.titleDecor} contentFit="contain" />
-
- <TouchableOpacity style={styles.closeBtn} onPress={() => setVisible(false)}>
- <Text style={styles.closeText}>×</Text>
- </TouchableOpacity>
- </View>
- {/* Tabs for Ranges */}
- {tabs.length > 1 && (
- <View style={styles.tabsContainer}>
- <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.tabsScroll}>
- {tabs.map(tab => (
- <TouchableOpacity
- key={tab.title}
- style={[styles.tab, activeTab?.value === tab.value && styles.activeTab]}
- onPress={() => handleTabChange(tab)}
- >
- <Text style={[styles.tabText, activeTab?.value === tab.value && styles.activeTabText]}>
- {tab.title}
- </Text>
- </TouchableOpacity>
- ))}
- </ScrollView>
- </View>
- )}
- {/* Grid */}
- <ScrollView contentContainerStyle={styles.grid}>
- {activeTab?.data.map((num: number) => renderItem(num))}
- </ScrollView>
- {/* Bottom Selection Bar */}
- <View style={styles.bottomSection}>
- <ScrollView horizontal style={styles.selectedScroll} contentContainerStyle={styles.selectedContent}>
- {selectedList.map((num) => (
- <View key={num} style={styles.selectedChip}>
- <Text style={styles.selectedChipText}>{num}号</Text>
- <TouchableOpacity onPress={() => toggleSelect(Number(num))} style={styles.chipClose}>
- <Text style={styles.chipCloseText}>×</Text>
- </TouchableOpacity>
- </View>
- ))}
- </ScrollView>
- <TouchableOpacity
- style={styles.confirmBtn}
- onPress={handleConfirm}
- disabled={loading}
- >
- <ImageBackground source={{ uri: Images.common.loginBg }} style={styles.confirmBtnBg} resizeMode="stretch">
- {loading ? <ActivityIndicator color="#fff" /> : (
- <View style={{ alignItems: 'center' }}>
- <Text style={styles.confirmText}>确定选择</Text>
- <Text style={styles.subConfirmText}>已选择({selectedList.length})发</Text>
- </View>
- )}
- </ImageBackground>
- </TouchableOpacity>
- </View>
- </ImageBackground>
- </View>
- </Modal>
- );
- }
- );
- const styles = StyleSheet.create({
- overlay: {
- flex: 1,
- backgroundColor: 'rgba(0,0,0,0.5)',
- justifyContent: 'flex-end',
- },
- mask: { flex: 1 },
- container: {
- height: 600,
- paddingTop: 15,
- borderTopLeftRadius: 15,
- borderTopRightRadius: 15,
- overflow: 'hidden',
- backgroundColor: '#fff',
- },
- header: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'center',
- marginBottom: 15,
- position: 'relative',
- height: 44,
- },
- title: {
- fontSize: 18,
- fontWeight: 'bold',
- color: '#fff',
- marginHorizontal: 10,
- textShadowColor: '#000',
- textShadowOffset: { width: 1, height: 1 },
- textShadowRadius: 1,
- },
- titleDecor: {
- width: 17,
- height: 19,
- },
- closeBtn: {
- position: 'absolute',
- right: 15,
- top: 0,
- width: 24,
- height: 24,
- backgroundColor: '#ebebeb',
- borderRadius: 12,
- justifyContent: 'center',
- alignItems: 'center',
- },
- closeText: { fontSize: 18, color: '#a2a2a2', marginTop: -2 },
- tabsContainer: {
- height: 40,
- marginBottom: 10,
- },
- tabsScroll: {
- paddingHorizontal: 10,
- },
- tab: {
- paddingVertical: 6,
- paddingHorizontal: 12,
- backgroundColor: '#f5f5f5',
- borderRadius: 20,
- marginRight: 10,
- justifyContent: 'center',
- },
- activeTab: { backgroundColor: '#FFC900' },
- tabText: { fontSize: 12, color: '#666' },
- activeTabText: { color: '#000', fontWeight: 'bold' },
- grid: {
- flexDirection: 'row',
- flexWrap: 'wrap',
- paddingHorizontal: 10,
- paddingBottom: 20,
- },
- item: {
- width: (SCREEN_WIDTH - 20 - 45) / 5, // Approx 5 items per row
- height: ((SCREEN_WIDTH - 20 - 45) / 5) * 0.5,
- margin: 4,
- backgroundColor: '#FFC900',
- borderWidth: 3,
- borderColor: '#000',
- justifyContent: 'center',
- alignItems: 'center',
- borderRadius: 0,
- },
- itemText: { fontSize: 12, fontWeight: 'bold', color: '#000' },
- disabledText: { color: 'rgba(255,255,255,0.3)' },
- levelItem: { backgroundColor: '#e8e8e8', borderColor: '#e8e8e8' },
- lockedItem: { backgroundColor: 'rgba(98, 99, 115, 0.3)', borderColor: 'transparent', borderWidth: 0 },
- selectedItem: { backgroundColor: '#FFC900', borderColor: '#000' }, // Same as default but with icon
- usedContent: { alignItems: 'center' },
- usedNum: { fontSize: 10, opacity: 0.5 },
- levelText: { fontSize: 10, fontWeight: 'bold' },
- lockIcon: { position: 'absolute', bottom: 0, right: 0, backgroundColor: '#000', padding: 2, borderTopLeftRadius: 5 },
- checkIcon: {
- position: 'absolute',
- bottom: 0,
- right: 0,
- backgroundColor: '#fff',
- paddingHorizontal: 4,
- borderTopLeftRadius: 5,
- },
- bottomSection: {
- paddingBottom: 30,
- paddingTop: 10,
- borderTopWidth: 1,
- borderColor: '#eee',
- backgroundColor: '#fff',
- },
- selectedScroll: { maxHeight: 50, marginBottom: 10 },
- selectedContent: { paddingHorizontal: 10 },
- selectedChip: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: '#FFC900',
- borderWidth: 2,
- borderColor: '#000',
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 4,
- marginRight: 8,
- height: 30,
- },
- selectedChipText: { fontSize: 12, fontWeight: 'bold', marginRight: 5 },
- chipClose: { backgroundColor: 'rgba(255,255,255,0.5)', borderRadius: 10, width: 16, height: 16, alignItems: 'center', justifyContent: 'center' },
- chipCloseText: { fontSize: 12, lineHeight: 14 },
- confirmBtn: {
- alignSelf: 'center',
- width: 200,
- height: 50,
- },
- confirmBtnBg: {
- flex: 1,
- justifyContent: 'center',
- alignItems: 'center',
- },
- confirmText: {
- color: '#fff',
- fontSize: 16,
- fontWeight: 'bold',
- textShadowColor: '#000',
- textShadowOffset: { width: 1, height: 1 },
- textShadowRadius: 1,
- },
- subConfirmText: {
- color: '#fff',
- fontSize: 10,
- marginTop: -2,
- },
- });
|