multipleSelector.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. <template>
  2. <view>
  3. <!-- 表单输入框 -->
  4. <view v-if="formInput">
  5. <uni-easyinput
  6. v-model="inputVal"
  7. type="text"
  8. :placeholder="placeholder"
  9. :clearable="clearable"
  10. :readonly="readonly"
  11. @focus.stop="handleOpen('focus')"
  12. ></uni-easyinput>
  13. </view>
  14. <!-- 插槽 -->
  15. <view v-else @tap="handleOpen('slot')">
  16. <slot></slot>
  17. </view>
  18. <!-- 弹窗 -->
  19. <uni-popup ref="popup">
  20. <view class="popup-content" :style="popupStyle">
  21. <view class="popup-header">
  22. <view class="popup-title">{{ label }}</view>
  23. <uni-icons
  24. type="closeempty"
  25. size="24"
  26. color="#999"
  27. @tap="closePopup"
  28. />
  29. </view>
  30. <view class="popup-search" v-if="filter">
  31. <uni-easyinput
  32. v-model="keyword"
  33. type="text"
  34. :placeholder="searchPlaceholder"
  35. @input="inputChange"
  36. suffixIcon="search"
  37. ></uni-easyinput>
  38. </view>
  39. <scroll-view
  40. class="popup-body"
  41. scroll-y
  42. :style="{ height: bodyHeight + 'px' }"
  43. >
  44. <view
  45. v-for="(level, levelIndex) in showList"
  46. :key="levelIndex"
  47. class="level-container"
  48. >
  49. <view v-if="!level.data?.length">
  50. {{ keyword ? '没有匹配的数据' : '暂无数据' }}
  51. </view>
  52. <template v-else>
  53. <view
  54. v-for="item in level.data"
  55. :key="item[itemValue]"
  56. class="item-container"
  57. :class="calcClass(item, levelIndex)"
  58. @tap="handleItemClick(item, levelIndex)"
  59. >
  60. <text class="item-label">{{ item[itemLabel] }}</text>
  61. <view class="item-icons">
  62. <uni-icons
  63. v-if="isItemActive(item, levelIndex) && !hasChildren(item)"
  64. type="checkmarkempty"
  65. color="#00B760"
  66. size="16"
  67. />
  68. <uni-icons
  69. v-if="hasChildren(item)"
  70. type="arrowright"
  71. color="#999"
  72. size="16"
  73. />
  74. </view>
  75. </view>
  76. </template>
  77. </view>
  78. </scroll-view>
  79. <view class="popup-footer" v-if="showFooter || multiple || isTreeData">
  80. <button
  81. v-if="clearable"
  82. class="btn cancel"
  83. @tap="handleClear"
  84. >
  85. {{ clearText }}
  86. </button>
  87. <button
  88. class="btn confirm"
  89. @tap="handleConfirm"
  90. :disabled="!hasSelection"
  91. >
  92. {{ submitText }}
  93. </button>
  94. </view>
  95. </view>
  96. </uni-popup>
  97. </view>
  98. </template>
  99. <script setup>
  100. import { ref, computed, watch, nextTick } from 'vue'
  101. import { debounce } from 'lodash-es'
  102. const emit = defineEmits(['update:value', 'update:text', 'change', 'search'])
  103. const props = defineProps({
  104. // 基础属性
  105. value: [String, Number, Array, Object],
  106. text: String,
  107. items: {
  108. type: Array,
  109. default: () => []
  110. },
  111. label: {
  112. type: String,
  113. default: '请选择'
  114. },
  115. placeholder: {
  116. type: String,
  117. default: '请选择'
  118. },
  119. // 字段映射
  120. itemLabel: {
  121. type: String,
  122. default: 'label'
  123. },
  124. itemValue: {
  125. type: String,
  126. default: 'value'
  127. },
  128. children: {
  129. type: String,
  130. default: 'children'
  131. },
  132. // 功能开关
  133. multiple: Boolean,
  134. formInput: Boolean, // 表单右侧输入框
  135. filter: Boolean, // 可检索
  136. clearable: Boolean,
  137. readonly: Boolean,
  138. oneLevel: Boolean, // 不展示子级
  139. showFooter: {
  140. type: Boolean,
  141. default: false
  142. },
  143. // 搜索相关
  144. searchPlaceholder: {
  145. type: String,
  146. default: '请输入'
  147. },
  148. searchDebounceTime: {
  149. type: Number,
  150. default: 500
  151. },
  152. // 样式
  153. popupStyle: [String, Object],
  154. // 文本自定义
  155. clearText: {
  156. type: String,
  157. default: '清除'
  158. },
  159. submitText: {
  160. type: String,
  161. default: '确定'
  162. }
  163. })
  164. // 使用 lodash 的防抖
  165. const debouncedSearch = debounce((value) => {
  166. if (value = '') {
  167. showList.value = [{
  168. choose: props.multiple ? [] : -1,
  169. data: props.items
  170. }]
  171. } else {
  172. showList.value = [{
  173. choose: showList.value?.choose || -1,
  174. data: props.items?.length && props.items.map(j => j[props.itemLabel] && j[props.itemLabel].includes(value))
  175. }]
  176. }
  177. emit('search', value)
  178. }, props.searchDebounceTime)
  179. const isTreeData = ref(false)
  180. // 组件引用
  181. const popup = ref()
  182. const popupContent = ref()
  183. // 数据状态
  184. const inputVal = ref('')
  185. const keyword = ref('')
  186. const showList = ref([])
  187. const bodyHeight = ref(400)
  188. // 计算属性
  189. const hasSelection = computed(() => {
  190. if (props.multiple) {
  191. return showList.value.some(level => Array.isArray(level.choose) && level.choose.length > 0)
  192. }
  193. return showList.value.some(level => level.choose !== -1)
  194. })
  195. // 初始化
  196. const initShowList = () => {
  197. showList.value = [{
  198. choose: props.multiple ? [] : -1,
  199. data: props.items
  200. }]
  201. }
  202. const isItemActive = (item, levelIndex) => {
  203. const level = showList.value[levelIndex]
  204. if (!level) return false
  205. if (Array.isArray(level.choose)) {
  206. return level.choose.includes(item[props.itemValue])
  207. }
  208. return level.choose === item[props.itemValue]
  209. }
  210. const hasChildren = (item) => {
  211. return props.oneLevel ? false : Boolean(item[props.children] && item[props.children].length > 0)
  212. }
  213. // 方法
  214. const calcClass = (item, levelIndex) => {
  215. return `${isItemActive(item, levelIndex) ? 'active' : ''}${hasChildren(item) ? 'has-children' : ''}`
  216. }
  217. const handleOpen = async () => {
  218. popup.value.open('bottom')
  219. await nextTick()
  220. calculateBodyHeight()
  221. }
  222. const closePopup = () => {
  223. popup.value.close()
  224. }
  225. const calculateBodyHeight = () => {
  226. if (!popupContent.value) return
  227. const query = uni.createSelectorQuery().in(popupContent.value)
  228. query.select('.popup-content').boundingClientRect(data => {
  229. if (data) {
  230. bodyHeight.value = data.height - 120 // 减去头部和底部高度
  231. }
  232. }).exec()
  233. }
  234. const handleItemClick = (item, levelIndex) => {
  235. // if ((!props.multiple && !item?.children?.length) || props.oneLevel) {
  236. // inputVal.value = item[props.itemLabel]
  237. // emit('update:value', item[props.itemValue])
  238. // emit('change', item[props.itemValue], item)
  239. // closePopup()
  240. // return
  241. // }
  242. // 处理多选
  243. if (props.multiple) {
  244. handleMultiSelect(item, levelIndex)
  245. return
  246. }
  247. // 处理单选
  248. handleSingleSelect(item, levelIndex)
  249. }
  250. const handleSingleSelect = (item, levelIndex) => {
  251. const newShowList = [...showList.value]
  252. // 更新当前级别的选择
  253. newShowList[levelIndex].choose = item[props.itemValue]
  254. // 如果有子级,添加下一级
  255. if (!props.oneLevel && hasChildren(item)) {
  256. newShowList.splice(levelIndex + 1)
  257. newShowList.push({
  258. choose: -1,
  259. data: item[props.children]
  260. })
  261. } else {
  262. // 没有子级,截断后面的级别
  263. newShowList.splice(levelIndex + 1)
  264. // 最后一级,更新输出文本
  265. inputVal.value = item[props.itemLabel]
  266. emit('update:value', item[props.itemValue])
  267. emit('change', item[props.itemValue], item)
  268. closePopup()
  269. }
  270. showList.value = newShowList
  271. }
  272. const handleMultiSelect = (item, levelIndex) => {
  273. const newShowList = [...showList.value]
  274. const currentLevel = newShowList[levelIndex]
  275. // 初始化选择数组
  276. if (!Array.isArray(currentLevel.choose)) {
  277. currentLevel.choose = []
  278. }
  279. // 切换选择状态
  280. const index = currentLevel.choose.indexOf(item[props.itemValue])
  281. if (index === -1) {
  282. currentLevel.choose.push(item[props.itemValue])
  283. } else {
  284. currentLevel.choose.splice(index, 1)
  285. }
  286. showList.value = newShowList
  287. }
  288. const inputChange = () => {
  289. debouncedSearch(keyword.value)
  290. }
  291. const handleClear = () => {
  292. initShowList()
  293. inputVal.value = ''
  294. emit('update:value', props.multiple ? [] : null)
  295. emit('update:text', '')
  296. emit('change', props.multiple ? [] : null)
  297. closePopup()
  298. }
  299. const handleConfirm = () => {
  300. if (props.multiple) {
  301. const selectedValues = []
  302. const selectedLabels = []
  303. showList.value.forEach(level => {
  304. if (Array.isArray(level.choose)) {
  305. level.data.forEach(item => {
  306. if (level.choose.includes(item[props.itemValue])) {
  307. selectedValues.push(item[props.itemValue])
  308. selectedLabels.push(item[props.itemLabel])
  309. }
  310. })
  311. }
  312. })
  313. emit('update:value', selectedValues)
  314. emit('update:text', selectedLabels.join(','))
  315. emit('change', selectedValues)
  316. } else {
  317. const lastLevel = showList.value[showList.value.length - 1]
  318. const selectedItem = lastLevel.data.find(item => item[props.itemValue] === lastLevel.choose)
  319. if (selectedItem) {
  320. emit('update:value', selectedItem[props.itemValue])
  321. emit('update:text', selectedItem[props.itemLabel])
  322. emit('change', selectedItem[props.itemValue])
  323. }
  324. }
  325. closePopup()
  326. }
  327. // 监听props变化
  328. watch(() => props.items, initShowList, { immediate: true })
  329. watch(() => props.value, (newVal) => {
  330. // if (!newVal) {
  331. // initShowList()
  332. // inputVal.value = ''
  333. // return
  334. // }
  335. // TODO: 实现回显逻辑
  336. }, { immediate: true })
  337. watch(() => props.text, (newVal) => {
  338. inputVal.value = newVal || ''
  339. }, { immediate: true })
  340. </script>
  341. <style lang="scss" scoped>
  342. .popup-content {
  343. background: #fff;
  344. border-radius: 16rpx 16rpx 0 0;
  345. padding: 24rpx;
  346. box-sizing: border-box;
  347. display: flex;
  348. flex-direction: column;
  349. }
  350. .popup-header {
  351. display: flex;
  352. justify-content: space-between;
  353. align-items: center;
  354. padding-bottom: 16rpx;
  355. border-bottom: 1rpx solid #eee;
  356. }
  357. .popup-title {
  358. font-size: 32rpx;
  359. font-weight: bold;
  360. color: #333;
  361. }
  362. .popup-search {
  363. padding: 24rpx 0;
  364. }
  365. .popup-body {
  366. flex: 1;
  367. overflow: hidden;
  368. }
  369. .level-container {
  370. margin-bottom: 16rpx;
  371. }
  372. .item-container {
  373. display: flex;
  374. justify-content: space-between;
  375. align-items: center;
  376. padding: 20rpx 16rpx;
  377. border-radius: 8rpx;
  378. margin-bottom: 8rpx;
  379. &.active {
  380. background-color: #f5fff9;
  381. color: #00B760;
  382. }
  383. &.has-children {
  384. background-color: #f9f9f9;
  385. }
  386. }
  387. .item-label {
  388. flex: 1;
  389. font-size: 28rpx;
  390. }
  391. .item-icons {
  392. margin-left: 16rpx;
  393. }
  394. .popup-footer {
  395. display: flex;
  396. justify-content: space-between;
  397. padding-top: 24rpx;
  398. border-top: 1rpx solid #eee;
  399. .btn {
  400. flex: 1;
  401. height: 80rpx;
  402. line-height: 80rpx;
  403. font-size: 28rpx;
  404. border-radius: 8rpx;
  405. &.cancel {
  406. background: #f5f5f5;
  407. color: #666;
  408. margin-right: 16rpx;
  409. }
  410. &.confirm {
  411. background: #00B760;
  412. color: #fff;
  413. &[disabled] {
  414. opacity: 0.6;
  415. }
  416. }
  417. }
  418. }
  419. </style>