MultiSelectDropdown.vue 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. <template>
  2. <view class="multi-select-container">
  3. <!-- 选中标签展示区 -->
  4. <view
  5. class="selected-tags"
  6. :class="{ 'has-value': selectedItems.length > 0 }"
  7. @click="toggleDropdown"
  8. >
  9. <view class="tag-box" v-if="selectedItems.length > 0">
  10. <view
  11. class="tag-item"
  12. v-for="(item, index) in selectedItems"
  13. :key="index"
  14. @click.stop.prevent="removeItem(item)"
  15. >
  16. {{ item.name }}
  17. <u-icon name="close" size="20rpx" class="tag-close"></u-icon>
  18. </view>
  19. </view>
  20. <template v-else>
  21. <text class="placeholder">{{ placeholder }}</text>
  22. </template>
  23. <u-icon
  24. name="arrow-down"
  25. size="24rpx"
  26. class="dropdown-icon"
  27. :class="{ rotate: isOpen }"
  28. ></u-icon>
  29. </view>
  30. <!-- 下拉选项面板 -->
  31. <view
  32. class="dropdown-panel"
  33. v-if="isOpen"
  34. :style="{
  35. width: panelWidth,
  36. maxHeight: panelMaxHeight,
  37. }"
  38. @tap.stop
  39. >
  40. <!-- 一级选项 -->
  41. <view class="level1-wrapper">
  42. <view
  43. class="level1-item"
  44. v-for="level1 in options"
  45. :key="level1.id"
  46. :class="{ active: activeLevel1Id === level1.id }"
  47. @click="handleLevel1Click(level1.id)"
  48. >
  49. {{ level1.name }}
  50. </view>
  51. </view>
  52. <view class="level2-wrapper">
  53. <view
  54. class="level2-item"
  55. v-for="level2 in filteredLevel2"
  56. :key="level2.id"
  57. :class="{ checked: isSelected(level2) }"
  58. @click="toggleLevel2(level2)"
  59. >
  60. <text class="item-text">{{ level2.name }}</text>
  61. </view>
  62. <view class="empty-text" v-if="filteredLevel2.length === 0">
  63. <trans _t="暂无选项" />
  64. </view>
  65. </view>
  66. </view>
  67. <view v-if="isOpen" class="backdrop" @tap="close"></view>
  68. </view>
  69. </template>
  70. <script setup>
  71. import { ref, watch, computed, defineProps, defineEmits } from "vue";
  72. // 定义组件属性
  73. const props = defineProps({
  74. // 选项数据
  75. options: {
  76. type: Array,
  77. default: () => [],
  78. // 数据格式: [{
  79. // id: 1,
  80. // name: '一级选项1',
  81. // children: [{ id: 11, name: '二级选项1-1' }, ...]
  82. // }, ...]
  83. },
  84. // 占位文本
  85. placeholder: {
  86. type: String,
  87. default: "",
  88. },
  89. // 面板宽度
  90. panelWidth: {
  91. type: String,
  92. default: "500rpx",
  93. },
  94. // 面板最大高度
  95. panelMaxHeight: {
  96. type: String,
  97. default: "600rpx",
  98. },
  99. // 默认选中值
  100. modelValue: {
  101. type: Array,
  102. default: () => [],
  103. },
  104. });
  105. // 定义事件
  106. const emits = defineEmits(["update:modelValue", "change"]);
  107. // 状态管理
  108. const isOpen = ref(false); // 下拉面板是否展开
  109. const activeLevel1Id = ref(""); // 当前激活的一级选项ID
  110. const selectedItems = ref([]); // 选中的二级选项
  111. // 初始化
  112. watch(
  113. () => props.modelValue,
  114. (newVal) => {
  115. // 从外部值初始化选中项
  116. if (newVal && newVal.length > 0) {
  117. const items = [];
  118. props.options.forEach((level1) => {
  119. level1.children?.forEach((level2) => {
  120. if (newVal.includes(level2.id)) {
  121. items.push(level2);
  122. }
  123. });
  124. });
  125. selectedItems.value = items;
  126. }
  127. },
  128. { immediate: true }
  129. );
  130. // 初始化默认选中的一级选项
  131. watch(
  132. () => props.options,
  133. (newVal) => {
  134. if (newVal && newVal.length > 0 && !activeLevel1Id.value) {
  135. activeLevel1Id.value = newVal[0].id;
  136. }
  137. },
  138. { immediate: true }
  139. );
  140. // 过滤当前一级选项对应的二级选项
  141. const filteredLevel2 = computed(() => {
  142. const level1 = props.options.find((item) => item.id === activeLevel1Id.value);
  143. return level1?.children || [];
  144. });
  145. // 切换下拉面板
  146. const toggleDropdown = () => {
  147. isOpen.value = !isOpen.value;
  148. };
  149. // 点击一级选项
  150. const handleLevel1Click = (id) => {
  151. activeLevel1Id.value = id;
  152. };
  153. // 切换二级选项选中状态
  154. const toggleLevel2 = (item) => {
  155. const index = selectedItems.value.findIndex((i) => i.id === item.id);
  156. if (index > -1) {
  157. // 取消选中
  158. selectedItems.value.splice(index, 1);
  159. } else {
  160. // 选中
  161. selectedItems.value.push(item);
  162. }
  163. // 通知父组件
  164. emitChange();
  165. };
  166. // 判断二级选项是否选中
  167. const isSelected = (item) => {
  168. return selectedItems.value.some((i) => i.id === item.id);
  169. };
  170. // 移除选中项
  171. const removeItem = (item) => {
  172. const index = selectedItems.value.findIndex((i) => i.id === item.id);
  173. if (index > -1) {
  174. selectedItems.value.splice(index, 1);
  175. // 通知父组件
  176. emitChange();
  177. }
  178. };
  179. // 通知父组件变化
  180. const emitChange = () => {
  181. const ids = selectedItems.value.map((item) => item.id);
  182. emits("update:modelValue", ids);
  183. emits("change", {
  184. ids,
  185. items: [...selectedItems.value],
  186. });
  187. };
  188. const close = () => (isOpen.value = false);
  189. // 点击外部关闭下拉框
  190. watch(
  191. () => isOpen.value,
  192. (newVal) => {
  193. const handleClickOutside = (e) => {
  194. const dropdown = document.querySelector(".multi-select-container");
  195. if (dropdown && !dropdown.contains(e.target)) {
  196. isOpen.value = false;
  197. document.removeEventListener("click", handleClickOutside);
  198. }
  199. };
  200. if (newVal) {
  201. setTimeout(() => {
  202. document.addEventListener("click", handleClickOutside);
  203. }, 0);
  204. }
  205. }
  206. );
  207. </script>
  208. <style lang="less" scoped>
  209. .multi-select-container {
  210. position: relative;
  211. display: inline-block;
  212. // 选中标签区域
  213. .selected-tags {
  214. display: flex;
  215. // flex-wrap: wrap;
  216. align-items: center;
  217. max-width: 540rpx;
  218. min-height: 70rpx;
  219. // border: 2rpx solid #ddd;
  220. border-radius: 8rpx;
  221. // background-color: #fff;
  222. cursor: pointer;
  223. transition: all 0.2s;
  224. &.has-value {
  225. border-color: #3c9cff;
  226. }
  227. &:focus-within {
  228. border-color: #3c9cff;
  229. box-shadow: 0 0 0 2rpx rgba(60, 156, 255, 0.2);
  230. }
  231. .placeholder {
  232. color: #999;
  233. font-weight: 500;
  234. }
  235. .tag-box {
  236. display: flex;
  237. flex-wrap: wrap;
  238. align-items: center;
  239. }
  240. .tag-item {
  241. display: inline-flex;
  242. align-items: center;
  243. margin: 5rpx 8rpx 5rpx 0;
  244. padding: 5rpx 15rpx;
  245. background-color: #f0f7ff;
  246. color: #3c9cff;
  247. border-radius: 20rpx;
  248. font-size: 26rpx;
  249. .tag-close {
  250. margin-left: 8rpx;
  251. color: #3c9cff;
  252. opacity: 0.8;
  253. &:hover {
  254. opacity: 1;
  255. }
  256. }
  257. }
  258. .dropdown-icon {
  259. margin-left: 10rpx;
  260. color: #999;
  261. transition: transform 0.2s;
  262. &.rotate {
  263. transform: rotate(180deg);
  264. }
  265. }
  266. }
  267. // 下拉面板
  268. .dropdown-panel {
  269. position: absolute;
  270. top: 80rpx;
  271. right: 0;
  272. display: flex;
  273. border: 2rpx solid #eee;
  274. border-radius: 8rpx;
  275. background-color: #fff;
  276. box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.1);
  277. z-index: 999;
  278. overflow: hidden;
  279. // 一级选项
  280. .level1-wrapper {
  281. width: 35%;
  282. border-right: 2rpx solid #eee;
  283. background-color: #fafafa;
  284. overflow-y: auto;
  285. .level1-item {
  286. padding: 20rpx;
  287. font-size: 28rpx;
  288. color: #333;
  289. transition: all 0.2s;
  290. &:hover {
  291. background-color: #f0f0f0;
  292. }
  293. &.active {
  294. background-color: #fff;
  295. color: #3c9cff;
  296. font-weight: 500;
  297. }
  298. }
  299. }
  300. // 二级选项
  301. .level2-wrapper {
  302. width: 65%;
  303. padding: 10rpx;
  304. overflow-y: auto;
  305. .level2-item {
  306. display: flex;
  307. align-items: center;
  308. padding: 15rpx 20rpx;
  309. font-size: 28rpx;
  310. color: #333;
  311. border-radius: 6rpx;
  312. transition: background-color 0.2s;
  313. &:hover {
  314. background-color: #f5f7fa;
  315. }
  316. &.checked {
  317. background-color: #f0f7ff;
  318. color: #3c9cff;
  319. }
  320. .check-icon {
  321. margin-right: 15rpx;
  322. }
  323. }
  324. .empty-text {
  325. padding: 40rpx;
  326. text-align: center;
  327. font-size: 28rpx;
  328. color: #999;
  329. }
  330. }
  331. }
  332. .backdrop {
  333. position: fixed;
  334. left: 0;
  335. top: 0;
  336. right: 0;
  337. bottom: 0;
  338. z-index: 10;
  339. background: rgba(0, 0, 0, 0); /* 透明即可捕获点击 */
  340. }
  341. }
  342. // 解决点击事件冒泡问题
  343. ::v-deep .u-icon {
  344. pointer-events: none;
  345. }
  346. </style>