LocationPicker.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. <template>
  2. <!-- 编辑模式:输入框 -->
  3. <view v-if="mode === 'edit'" class="location-picker-wrapper">
  4. <view class="input-wrapper" @click="openPicker">
  5. <input
  6. class="location-input"
  7. :value="displayLabel"
  8. :placeholder="placeholder"
  9. readonly
  10. disabled
  11. />
  12. <view class="suffix-icons">
  13. <view
  14. v-if="innerValue && displayLabel"
  15. @click.stop="clearAddress"
  16. class="clear-btn"
  17. >
  18. <text class="clear-icon">×</text>
  19. </view>
  20. <text class="arrow-icon">></text>
  21. </view>
  22. </view>
  23. <!-- 自定义弹窗选择器 -->
  24. <view v-if="showPicker" class="picker-modal" @click="closePicker">
  25. <view class="picker-content" @click.stop>
  26. <view class="picker-header">
  27. <text class="picker-cancel" @click="closePicker">取消</text>
  28. <text class="picker-title">{{ popupTitle }}</text>
  29. <text class="picker-confirm" @click="confirmSelection">确定</text>
  30. </view>
  31. <view class="picker-body">
  32. <picker-view
  33. :value="pickerValue"
  34. @change="onPickerChange"
  35. class="picker-view"
  36. >
  37. <picker-view-column>
  38. <view class="picker-item" v-for="(item, index) in provinceList" :key="index">
  39. {{ item.text }}
  40. </view>
  41. </picker-view-column>
  42. <picker-view-column>
  43. <view class="picker-item" v-for="(item, index) in cityList" :key="index">
  44. {{ item.text }}
  45. </view>
  46. </picker-view-column>
  47. <picker-view-column>
  48. <view class="picker-item" v-for="(item, index) in districtList" :key="index">
  49. {{ item.text }}
  50. </view>
  51. </picker-view-column>
  52. </picker-view>
  53. </view>
  54. </view>
  55. </view>
  56. </view>
  57. <!-- 展示模式:纯文本 -->
  58. <text v-else class="location-text">
  59. {{ displayLabel || "无" }}
  60. </text>
  61. </template>
  62. <script setup>
  63. import { ref, watch, computed } from 'vue'
  64. import cityRows from '@/utils/data.json'
  65. // Props definition
  66. const props = defineProps({
  67. mode: {
  68. type: String,
  69. default: "edit" // edit / view
  70. },
  71. modelValue: {
  72. type: [String, Number],
  73. default: ""
  74. },
  75. label: {
  76. type: String,
  77. default: "所在地"
  78. },
  79. placeholder: {
  80. type: String,
  81. default: "请选择省市区"
  82. },
  83. popupTitle: {
  84. type: String,
  85. default: "请选择省市区"
  86. },
  87. required: {
  88. type: Boolean,
  89. default: false
  90. }
  91. })
  92. // Emits definition
  93. const emit = defineEmits(['update:modelValue', 'clear'])
  94. // Reactive data
  95. const innerValue = ref(props.modelValue)
  96. const displayLabel = ref('')
  97. const showPicker = ref(false)
  98. const pickerValue = ref([0, 0, 0]) // picker-view 的索引值
  99. const tempSelection = ref({ province: null, city: null, district: null }) // 临时选择
  100. // 省市区数据
  101. const localData = ref([])
  102. const provinceList = ref([])
  103. const cityList = ref([])
  104. const districtList = ref([])
  105. // 计算属性:根据当前选择更新城市和区县列表
  106. const updateCityList = () => {
  107. const provinceIndex = pickerValue.value[0]
  108. if (provinceList.value[provinceIndex] && provinceList.value[provinceIndex].children) {
  109. cityList.value = provinceList.value[provinceIndex].children
  110. } else {
  111. cityList.value = []
  112. }
  113. // 重置城市索引
  114. if (pickerValue.value[1] >= cityList.value.length) {
  115. pickerValue.value[1] = 0
  116. }
  117. }
  118. const updateDistrictList = () => {
  119. const cityIndex = pickerValue.value[1]
  120. if (cityList.value[cityIndex] && cityList.value[cityIndex].children) {
  121. districtList.value = cityList.value[cityIndex].children
  122. } else {
  123. districtList.value = []
  124. }
  125. // 重置区县索引
  126. if (pickerValue.value[2] >= districtList.value.length) {
  127. pickerValue.value[2] = 0
  128. }
  129. }
  130. // Watchers
  131. watch(() => props.modelValue, (newVal) => {
  132. console.log('props.modelValue 变化:', newVal)
  133. innerValue.value = newVal
  134. const label = getLocationLabel(newVal)
  135. displayLabel.value = label
  136. console.log('更新后的 displayLabel:', label)
  137. })
  138. watch(innerValue, (newVal) => {
  139. console.log('innerValue 变化:', newVal)
  140. emit("update:modelValue", newVal)
  141. })
  142. // Methods
  143. /** 打开选择器 */
  144. const openPicker = () => {
  145. console.log('打开地区选择器')
  146. showPicker.value = true
  147. // 如果有已选值,定位到对应位置
  148. if (innerValue.value) {
  149. locateSelection(innerValue.value)
  150. }
  151. }
  152. /** 关闭选择器 */
  153. const closePicker = () => {
  154. showPicker.value = false
  155. }
  156. /** picker-view 变化事件 */
  157. const onPickerChange = (e) => {
  158. const val = e.detail.value
  159. const oldValue = [...pickerValue.value]
  160. pickerValue.value = val
  161. // 如果省份改变,更新城市列表
  162. if (oldValue[0] !== val[0]) {
  163. updateCityList()
  164. updateDistrictList()
  165. }
  166. // 如果城市改变,更新区县列表
  167. else if (oldValue[1] !== val[1]) {
  168. updateDistrictList()
  169. }
  170. console.log('picker 值变化:', val)
  171. }
  172. /** 确认选择 */
  173. const confirmSelection = () => {
  174. const provinceIndex = pickerValue.value[0]
  175. const cityIndex = pickerValue.value[1]
  176. const districtIndex = pickerValue.value[2]
  177. const province = provinceList.value[provinceIndex]
  178. const city = cityList.value[cityIndex]
  179. const district = districtList.value[districtIndex]
  180. if (province && city && district) {
  181. // 更新值为区县的 code
  182. innerValue.value = district.value
  183. // 构建显示文本
  184. displayLabel.value = `${province.text} - ${city.text} - ${district.text}`
  185. console.log('确认选择:', displayLabel.value, '值:', innerValue.value)
  186. emit("update:modelValue", innerValue.value)
  187. }
  188. closePicker()
  189. }
  190. /** 清空地址 */
  191. const clearAddress = () => {
  192. console.log('清空地址')
  193. innerValue.value = ""
  194. displayLabel.value = ""
  195. pickerValue.value = [0, 0, 0]
  196. emit("update:modelValue", "")
  197. emit("clear")
  198. }
  199. /** 定位到已选择的值 */
  200. const locateSelection = (value) => {
  201. if (!value) return
  202. // 在数据中查找对应的省市区
  203. for (let i = 0; i < provinceList.value.length; i++) {
  204. const province = provinceList.value[i]
  205. if (province.children) {
  206. for (let j = 0; j < province.children.length; j++) {
  207. const city = province.children[j]
  208. if (city.children) {
  209. for (let k = 0; k < city.children.length; k++) {
  210. const district = city.children[k]
  211. if (district.value == value) {
  212. pickerValue.value = [i, j, k]
  213. updateCityList()
  214. updateDistrictList()
  215. console.log('定位到:', i, j, k)
  216. return
  217. }
  218. }
  219. }
  220. }
  221. }
  222. }
  223. }
  224. /** 回显文字(递归找路径) */
  225. const getLocationLabel = (value) => {
  226. console.log('getLocationLabel 被调用, value:', value)
  227. if (!value) return ""
  228. let label = ""
  229. const traverse = (nodes, currentPath = []) => {
  230. for (const node of nodes) {
  231. const newPath = [...currentPath, node.text]
  232. if (node.value == value) {
  233. label = newPath.join(' - ')
  234. console.log('找到匹配节点:', node, '路径:', label)
  235. return true
  236. }
  237. if (node.children && node.children.length > 0) {
  238. if (traverse(node.children, newPath)) {
  239. return true
  240. }
  241. }
  242. }
  243. return false
  244. }
  245. traverse(localData.value)
  246. console.log('最终返回的 label:', label)
  247. return label
  248. }
  249. /** 生成树数据 */
  250. const get_city_tree = () => {
  251. let res = []
  252. if (cityRows.length) {
  253. res = handleTree(cityRows)
  254. }
  255. return res
  256. }
  257. /** 递归组装树 */
  258. const handleTree = (data, parent_code = null) => {
  259. let res = []
  260. let keys = {
  261. id: "code",
  262. pid: "parent_code",
  263. children: "children",
  264. text: "name",
  265. value: "code"
  266. }
  267. for (let item of data) {
  268. if (parent_code === null) {
  269. // 顶级
  270. if (!item.hasOwnProperty(keys.pid) || item[keys.pid] == parent_code) {
  271. let node = {
  272. text: item[keys.text],
  273. value: item[keys.value],
  274. children: handleTree(data, item[keys.id])
  275. }
  276. res.push(node)
  277. }
  278. } else {
  279. // 子级
  280. if (item.hasOwnProperty(keys.pid) && item[keys.pid] == parent_code) {
  281. let node = {
  282. text: item[keys.text],
  283. value: item[keys.value],
  284. children: handleTree(data, item[keys.id])
  285. }
  286. res.push(node)
  287. }
  288. }
  289. }
  290. return res
  291. }
  292. // Initialize data
  293. localData.value = get_city_tree()
  294. provinceList.value = localData.value
  295. console.log('初始化 localData, 数据长度:', localData.value.length)
  296. // 初始化城市和区县列表
  297. if (provinceList.value.length > 0) {
  298. updateCityList()
  299. updateDistrictList()
  300. }
  301. // 初始化显示标签
  302. if (props.modelValue) {
  303. displayLabel.value = getLocationLabel(props.modelValue)
  304. console.log('初始化 displayLabel:', displayLabel.value)
  305. }
  306. </script>
  307. <style scoped>
  308. .location-picker-wrapper {
  309. width: 100%;
  310. }
  311. .input-wrapper {
  312. position: relative;
  313. display: flex;
  314. align-items: center;
  315. width: 100%;
  316. height: 44rpx;
  317. border-bottom: 1rpx solid #f0f0f0;
  318. padding: 12rpx 0;
  319. }
  320. .location-input {
  321. flex: 1;
  322. font-size: 28rpx;
  323. color: #333;
  324. height: 44rpx;
  325. line-height: 44rpx;
  326. }
  327. .location-input:disabled {
  328. background-color: transparent;
  329. color: #333;
  330. }
  331. .suffix-icons {
  332. display: flex;
  333. align-items: center;
  334. gap: 8rpx;
  335. }
  336. .clear-btn {
  337. display: flex;
  338. align-items: center;
  339. justify-content: center;
  340. width: 32rpx;
  341. height: 32rpx;
  342. }
  343. .clear-icon {
  344. font-size: 32rpx;
  345. color: #999;
  346. line-height: 1;
  347. }
  348. .arrow-icon {
  349. font-size: 24rpx;
  350. color: #999;
  351. }
  352. .location-text {
  353. font-size: 28rpx;
  354. color: #333;
  355. }
  356. /* 弹窗样式 */
  357. .picker-modal {
  358. position: fixed;
  359. top: 0;
  360. left: 0;
  361. right: 0;
  362. bottom: 0;
  363. background-color: rgba(0, 0, 0, 0.5);
  364. z-index: 9999;
  365. display: flex;
  366. align-items: flex-end;
  367. }
  368. .picker-content {
  369. width: 100%;
  370. background-color: #fff;
  371. border-radius: 24rpx 24rpx 0 0;
  372. overflow: hidden;
  373. }
  374. .picker-header {
  375. display: flex;
  376. justify-content: space-between;
  377. align-items: center;
  378. padding: 24rpx 32rpx;
  379. border-bottom: 1rpx solid #f0f0f0;
  380. }
  381. .picker-cancel,
  382. .picker-confirm {
  383. font-size: 28rpx;
  384. color: #007aff;
  385. padding: 8rpx 16rpx;
  386. }
  387. .picker-title {
  388. font-size: 32rpx;
  389. font-weight: bold;
  390. color: #333;
  391. }
  392. .picker-body {
  393. height: 500rpx;
  394. }
  395. .picker-view {
  396. width: 100%;
  397. height: 100%;
  398. }
  399. .picker-item {
  400. display: flex;
  401. align-items: center;
  402. justify-content: center;
  403. font-size: 28rpx;
  404. color: #333;
  405. height: 80rpx;
  406. line-height: 80rpx;
  407. }
  408. </style>