RegionPicker.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. import { getArea } from '@/services/address';
  2. import React, { useEffect, useState } from 'react';
  3. import {
  4. ActivityIndicator,
  5. Dimensions,
  6. Modal,
  7. ScrollView,
  8. StyleSheet,
  9. Text,
  10. TouchableOpacity,
  11. View,
  12. } from 'react-native';
  13. interface AreaItem {
  14. id: string;
  15. name: string;
  16. pid?: number;
  17. }
  18. interface RegionPickerProps {
  19. visible: boolean;
  20. onClose: () => void;
  21. onSelect: (province: string, city: string, district: string) => void;
  22. }
  23. const { width, height } = Dimensions.get('window');
  24. export const RegionPicker: React.FC<RegionPickerProps> = ({
  25. visible,
  26. onClose,
  27. onSelect,
  28. }) => {
  29. const [loading, setLoading] = useState(false);
  30. const [tabs, setTabs] = useState(['请选择', '', '']);
  31. const [activeTab, setActiveTab] = useState(0);
  32. const [provinces, setProvinces] = useState<AreaItem[]>([]);
  33. const [cities, setCities] = useState<AreaItem[]>([]);
  34. const [districts, setDistricts] = useState<AreaItem[]>([]);
  35. const [selectedProvince, setSelectedProvince] = useState<AreaItem | null>(null);
  36. const [selectedCity, setSelectedCity] = useState<AreaItem | null>(null);
  37. // District selection ends the flow
  38. useEffect(() => {
  39. if (visible && provinces.length === 0) {
  40. loadProvinces();
  41. }
  42. }, [visible]);
  43. const loadProvinces = async () => {
  44. setLoading(true);
  45. try {
  46. // 省级节点的 pid 通常为 1(中国节点),先尝试 1,空则回退 0
  47. let list = await getArea(1);
  48. if (!list || list.length === 0) {
  49. list = await getArea(0);
  50. }
  51. setProvinces(list || []);
  52. } catch (e) {
  53. console.error('加载省份数据失败:', e);
  54. }
  55. setLoading(false);
  56. };
  57. const handleSelect = async (item: AreaItem) => {
  58. if (activeTab === 0) {
  59. // Province selected
  60. setSelectedProvince(item);
  61. setTabs([item.name, '请选择', '']);
  62. setActiveTab(1);
  63. setLoading(true);
  64. const list = await getArea(Number(item.id));
  65. setCities(list || []);
  66. setLoading(false);
  67. } else if (activeTab === 1) {
  68. // City selected
  69. setSelectedCity(item);
  70. setTabs([selectedProvince!.name, item.name, '请选择']);
  71. setActiveTab(2);
  72. setLoading(true);
  73. const list = await getArea(Number(item.id));
  74. setDistricts(list || []);
  75. setLoading(false);
  76. } else {
  77. // District selected
  78. onSelect(selectedProvince!.name, selectedCity!.name, item.name);
  79. onClose();
  80. }
  81. };
  82. const handleTabPress = (index: number) => {
  83. // Only allow going back to previous tabs if data exists
  84. if (index < activeTab) {
  85. setActiveTab(index);
  86. }
  87. };
  88. const renderList = () => {
  89. let data : AreaItem[] = [];
  90. if (activeTab === 0) data = provinces;
  91. else if (activeTab === 1) data = cities;
  92. else data = districts;
  93. // Filter out invalid items if strictly needed, but let's trust API
  94. return (
  95. <ScrollView contentContainerStyle={styles.listContent}>
  96. {data.map((item) => {
  97. const isSelected =
  98. (activeTab === 0 && item.id === selectedProvince?.id) ||
  99. (activeTab === 1 && item.id === selectedCity?.id);
  100. return (
  101. <TouchableOpacity
  102. key={item.id}
  103. style={styles.item}
  104. onPress={() => handleSelect(item)}
  105. >
  106. <Text style={[styles.itemText, isSelected && styles.itemTextActive]}>
  107. {item.name}
  108. </Text>
  109. {isSelected && <Text style={styles.checkIcon}>✓</Text>}
  110. </TouchableOpacity>
  111. )})}
  112. </ScrollView>
  113. );
  114. };
  115. return (
  116. <Modal
  117. visible={visible}
  118. transparent
  119. animationType="slide"
  120. onRequestClose={onClose}
  121. >
  122. <View style={styles.mask}>
  123. <TouchableOpacity style={styles.maskClickable} onPress={onClose} />
  124. <View style={styles.container}>
  125. <View style={styles.header}>
  126. <Text style={styles.title}>配送至</Text>
  127. <TouchableOpacity onPress={onClose} style={styles.closeBtn}>
  128. <Text style={styles.closeText}>✕</Text>
  129. </TouchableOpacity>
  130. </View>
  131. <View style={styles.tabs}>
  132. {tabs.map((tab, index) => (
  133. tab ? (
  134. <TouchableOpacity
  135. key={index}
  136. onPress={() => handleTabPress(index)}
  137. style={[styles.tabItem, activeTab === index && styles.tabItemActive]}
  138. disabled={!tab || (index > activeTab)}
  139. >
  140. <Text style={[styles.tabText, activeTab === index && styles.tabTextActive]}>
  141. {tab}
  142. </Text>
  143. {activeTab === index && <View style={styles.tabLine} />}
  144. </TouchableOpacity>
  145. ) : null
  146. ))}
  147. </View>
  148. {loading ? (
  149. <View style={styles.loadingBox}>
  150. <ActivityIndicator size="small" color="#e79018" />
  151. </View>
  152. ) : (
  153. renderList()
  154. )}
  155. </View>
  156. </View>
  157. </Modal>
  158. );
  159. };
  160. const styles = StyleSheet.create({
  161. mask: {
  162. flex: 1,
  163. backgroundColor: 'rgba(0,0,0,0.5)',
  164. justifyContent: 'flex-end',
  165. },
  166. maskClickable: {
  167. flex: 1,
  168. },
  169. container: {
  170. height: height * 0.7, // 70% height
  171. backgroundColor: '#fff',
  172. borderTopLeftRadius: 16,
  173. borderTopRightRadius: 16,
  174. },
  175. header: {
  176. flexDirection: 'row',
  177. alignItems: 'center',
  178. justifyContent: 'center', // Title centered
  179. height: 50,
  180. borderBottomWidth: 1,
  181. borderBottomColor: '#eee',
  182. position: 'relative',
  183. },
  184. title: {
  185. fontSize: 16,
  186. fontWeight: 'bold',
  187. color: '#333',
  188. },
  189. closeBtn: {
  190. position: 'absolute',
  191. right: 15,
  192. top: 0,
  193. bottom: 0,
  194. justifyContent: 'center',
  195. paddingHorizontal: 10,
  196. },
  197. closeText: {
  198. fontSize: 18,
  199. color: '#999',
  200. },
  201. tabs: {
  202. flexDirection: 'row',
  203. borderBottomWidth: 1,
  204. borderBottomColor: '#eee',
  205. paddingHorizontal: 15,
  206. },
  207. tabItem: {
  208. marginRight: 25,
  209. height: 44,
  210. justifyContent: 'center',
  211. position: 'relative',
  212. },
  213. tabItemActive: {},
  214. tabText: {
  215. fontSize: 14,
  216. color: '#333',
  217. },
  218. tabTextActive: {
  219. color: '#e79018',
  220. fontWeight: 'bold',
  221. },
  222. tabLine: {
  223. position: 'absolute',
  224. bottom: 0,
  225. left: '10%',
  226. width: '80%',
  227. height: 2,
  228. backgroundColor: '#e79018',
  229. },
  230. loadingBox: {
  231. flex: 1,
  232. justifyContent: 'center',
  233. alignItems: 'center',
  234. },
  235. listContent: {
  236. paddingBottom: 40,
  237. },
  238. item: {
  239. flexDirection: 'row',
  240. alignItems: 'center',
  241. justifyContent: 'space-between',
  242. paddingVertical: 14,
  243. paddingHorizontal: 15,
  244. borderBottomWidth: StyleSheet.hairlineWidth,
  245. borderBottomColor: '#f5f5f5',
  246. },
  247. itemText: {
  248. fontSize: 14,
  249. color: '#333',
  250. },
  251. itemTextActive: {
  252. color: '#e79018',
  253. },
  254. checkIcon: {
  255. color: '#e79018',
  256. fontSize: 16,
  257. },
  258. });