LocationPicker.vue 11 KB

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