multipleSelector.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  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. debugger
  177. }
  178. emit('search', value)
  179. }, props.searchDebounceTime)
  180. const isTreeData = ref(false)
  181. // 组件引用
  182. const popup = ref()
  183. const popupContent = ref()
  184. // 数据状态
  185. const inputVal = ref('')
  186. const keyword = ref('')
  187. const showList = ref([])
  188. const bodyHeight = ref(400)
  189. // 计算属性
  190. const hasSelection = computed(() => {
  191. if (props.multiple) {
  192. return showList.value.some(level => Array.isArray(level.choose) && level.choose.length > 0)
  193. }
  194. return showList.value.some(level => level.choose !== -1)
  195. })
  196. // 初始化
  197. const initShowList = () => {
  198. showList.value = [{
  199. choose: props.multiple ? [] : -1,
  200. data: props.items
  201. }]
  202. }
  203. const isItemActive = (item, levelIndex) => {
  204. const level = showList.value[levelIndex]
  205. if (!level) return false
  206. if (Array.isArray(level.choose)) {
  207. return level.choose.includes(item[props.itemValue])
  208. }
  209. return level.choose === item[props.itemValue]
  210. }
  211. const hasChildren = (item) => {
  212. return props.oneLevel ? false : Boolean(item[props.children] && item[props.children].length > 0)
  213. }
  214. // 方法
  215. const calcClass = (item, levelIndex) => {
  216. return `${isItemActive(item, levelIndex) ? 'active' : ''}${hasChildren(item) ? 'has-children' : ''}`
  217. }
  218. const handleOpen = async () => {
  219. popup.value.open('bottom')
  220. await nextTick()
  221. calculateBodyHeight()
  222. }
  223. const closePopup = () => {
  224. popup.value.close()
  225. }
  226. const calculateBodyHeight = () => {
  227. if (!popupContent.value) return
  228. const query = uni.createSelectorQuery().in(popupContent.value)
  229. query.select('.popup-content').boundingClientRect(data => {
  230. if (data) {
  231. bodyHeight.value = data.height - 120 // 减去头部和底部高度
  232. }
  233. }).exec()
  234. }
  235. const handleItemClick = (item, levelIndex) => {
  236. // if ((!props.multiple && !item?.children?.length) || props.oneLevel) {
  237. // inputVal.value = item[props.itemLabel]
  238. // emit('update:value', item[props.itemValue])
  239. // emit('change', item[props.itemValue], item)
  240. // closePopup()
  241. // return
  242. // }
  243. // 处理多选
  244. if (props.multiple) {
  245. handleMultiSelect(item, levelIndex)
  246. return
  247. }
  248. // 处理单选
  249. handleSingleSelect(item, levelIndex)
  250. }
  251. const handleSingleSelect = (item, levelIndex) => {
  252. const newShowList = [...showList.value]
  253. // 更新当前级别的选择
  254. newShowList[levelIndex].choose = item[props.itemValue]
  255. // 如果有子级,添加下一级
  256. if (!props.oneLevel && hasChildren(item)) {
  257. newShowList.splice(levelIndex + 1)
  258. newShowList.push({
  259. choose: -1,
  260. data: item[props.children]
  261. })
  262. } else {
  263. // 没有子级,截断后面的级别
  264. newShowList.splice(levelIndex + 1)
  265. // 最后一级,更新输出文本
  266. inputVal.value = item[props.itemLabel]
  267. emit('update:value', item[props.itemValue])
  268. emit('change', item[props.itemValue], item)
  269. closePopup()
  270. }
  271. showList.value = newShowList
  272. }
  273. const handleMultiSelect = (item, levelIndex) => {
  274. const newShowList = [...showList.value]
  275. const currentLevel = newShowList[levelIndex]
  276. // 初始化选择数组
  277. if (!Array.isArray(currentLevel.choose)) {
  278. currentLevel.choose = []
  279. }
  280. // 切换选择状态
  281. const index = currentLevel.choose.indexOf(item[props.itemValue])
  282. if (index === -1) {
  283. currentLevel.choose.push(item[props.itemValue])
  284. } else {
  285. currentLevel.choose.splice(index, 1)
  286. }
  287. showList.value = newShowList
  288. }
  289. const inputChange = () => {
  290. debouncedSearch(keyword.value)
  291. }
  292. const handleClear = () => {
  293. initShowList()
  294. inputVal.value = ''
  295. emit('update:value', props.multiple ? [] : null)
  296. emit('update:text', '')
  297. emit('change', props.multiple ? [] : null)
  298. closePopup()
  299. }
  300. const handleConfirm = () => {
  301. if (props.multiple) {
  302. const selectedValues = []
  303. const selectedLabels = []
  304. showList.value.forEach(level => {
  305. if (Array.isArray(level.choose)) {
  306. level.data.forEach(item => {
  307. if (level.choose.includes(item[props.itemValue])) {
  308. selectedValues.push(item[props.itemValue])
  309. selectedLabels.push(item[props.itemLabel])
  310. }
  311. })
  312. }
  313. })
  314. emit('update:value', selectedValues)
  315. emit('update:text', selectedLabels.join(','))
  316. emit('change', selectedValues)
  317. } else {
  318. const lastLevel = showList.value[showList.value.length - 1]
  319. const selectedItem = lastLevel.data.find(item => item[props.itemValue] === lastLevel.choose)
  320. if (selectedItem) {
  321. emit('update:value', selectedItem[props.itemValue])
  322. emit('update:text', selectedItem[props.itemLabel])
  323. emit('change', selectedItem[props.itemValue])
  324. }
  325. }
  326. closePopup()
  327. }
  328. // 监听props变化
  329. watch(() => props.items, initShowList, { immediate: true })
  330. watch(() => props.value, (newVal) => {
  331. // if (!newVal) {
  332. // initShowList()
  333. // inputVal.value = ''
  334. // return
  335. // }
  336. // TODO: 实现回显逻辑
  337. }, { immediate: true })
  338. watch(() => props.text, (newVal) => {
  339. inputVal.value = newVal || ''
  340. }, { immediate: true })
  341. </script>
  342. <style lang="scss" scoped>
  343. .popup-content {
  344. background: #fff;
  345. border-radius: 16rpx 16rpx 0 0;
  346. padding: 24rpx;
  347. box-sizing: border-box;
  348. display: flex;
  349. flex-direction: column;
  350. }
  351. .popup-header {
  352. display: flex;
  353. justify-content: space-between;
  354. align-items: center;
  355. padding-bottom: 16rpx;
  356. border-bottom: 1rpx solid #eee;
  357. }
  358. .popup-title {
  359. font-size: 32rpx;
  360. font-weight: bold;
  361. color: #333;
  362. }
  363. .popup-search {
  364. padding: 24rpx 0;
  365. }
  366. .popup-body {
  367. flex: 1;
  368. overflow: hidden;
  369. }
  370. .level-container {
  371. margin-bottom: 16rpx;
  372. }
  373. .item-container {
  374. display: flex;
  375. justify-content: space-between;
  376. align-items: center;
  377. padding: 20rpx 16rpx;
  378. border-radius: 8rpx;
  379. margin-bottom: 8rpx;
  380. &.active {
  381. background-color: #f5fff9;
  382. color: #00B760;
  383. }
  384. &.has-children {
  385. background-color: #f9f9f9;
  386. }
  387. }
  388. .item-label {
  389. flex: 1;
  390. font-size: 28rpx;
  391. }
  392. .item-icons {
  393. margin-left: 16rpx;
  394. }
  395. .popup-footer {
  396. display: flex;
  397. justify-content: space-between;
  398. padding-top: 24rpx;
  399. border-top: 1rpx solid #eee;
  400. .btn {
  401. flex: 1;
  402. height: 80rpx;
  403. line-height: 80rpx;
  404. font-size: 28rpx;
  405. border-radius: 8rpx;
  406. &.cancel {
  407. background: #f5f5f5;
  408. color: #666;
  409. margin-right: 16rpx;
  410. }
  411. &.confirm {
  412. background: #00B760;
  413. color: #fff;
  414. &[disabled] {
  415. opacity: 0.6;
  416. }
  417. }
  418. }
  419. }
  420. </style>