select.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425
  1. <template>
  2. <view class="custom-select-container" :style="{ width: width, height: height }" @click.stop="toggleDropdown">
  3. <!-- 插槽或默认触发器 -->
  4. <slot>
  5. <!-- 单选默认样式 -->
  6. <view v-if="!multiple" class="select-modal-inputwrap">
  7. <view class="select-modal-static">
  8. {{ selectedLabel || placeholder }}
  9. </view>
  10. <view class="select-input-arrow"></view>
  11. </view>
  12. <!-- 多选默认样式 -->
  13. <view v-else class="select-modal-inputwrap">
  14. <view class="select-modal-static" :style="{ maxHeight: 'auto', overflowY: 'visible' }">
  15. <block v-if="Array.isArray(modelValue) && modelValue.length">
  16. <view :style="{ maxHeight: '100rpx', overflowY: 'auto', paddingRight: '40rpx' }">
  17. <text v-for="(item, i) in selectedOptions" :key="i" class="customer-chip">
  18. {{ item.label }}
  19. <text class="customer-chip-close" @click.stop="removeOption(item)">×</text>
  20. </text>
  21. </view>
  22. </block>
  23. <text v-else>{{ placeholder }}</text>
  24. <text v-if="modelValue && modelValue.length" class="customer-clear-all" @click.stop="clearAll">×</text>
  25. </view>
  26. <view class="select-input-arrow"></view>
  27. </view>
  28. </slot>
  29. <!-- 下拉列表 -->
  30. <view v-if="isOpen" class="select-modal-dropdown" :style="dropdownStyle" @click.stop>
  31. <!-- 搜索框 -->
  32. <view v-if="searchable" class="select-dropdown-search-row">
  33. <input class="select-dropdown-search-input" v-model="searchText" @input="onSearchInput"
  34. placeholder="搜索..." />
  35. </view>
  36. <!-- 列表 -->
  37. <scroll-view scroll-y :style="{ maxHeight: dropdownScrollHeight }" @scrolltolower="loadMore">
  38. <view class="select-modal-dropdown-item" :class="{ 'selected': isSelected(op) }"
  39. v-for="(op, i) in displayList" :key="i" @click.stop="onSelect(op)">
  40. {{ op.label }}
  41. </view>
  42. <view v-if="displayList.length === 0" class="select-modal-dropdown-item">{{ emptyText }}</view>
  43. <view v-if="hasMore" class="select-loading-row">
  44. <view class="select-loading-wrapper">
  45. <image class="select-loading-icon" src="../static/images/loading.png" />
  46. </view>
  47. </view>
  48. </scroll-view>
  49. </view>
  50. </view>
  51. </template>
  52. <script>
  53. import { getPaginatedList } from '../utils/utils.js';
  54. export default {
  55. name: 'Select',
  56. props: {
  57. // 下拉列表数据,格式为 {label: string, value: string}[]
  58. options: {
  59. type: Array,
  60. default: () => []
  61. },
  62. // 已选项
  63. modelValue: {
  64. type: [String, Number, Array],
  65. default: ''
  66. },
  67. // 控制下拉框显示方向(上或下) 'top' | 'bottom'
  68. direction: {
  69. type: String,
  70. default: 'bottom'
  71. },
  72. // 控制选择框宽度
  73. width: {
  74. type: String,
  75. default: '100%'
  76. },
  77. // 控制选择框高度
  78. height: {
  79. type: String,
  80. default: 'auto'
  81. },
  82. // 未选择时的提示文案
  83. placeholder: {
  84. type: String,
  85. default: '请选择'
  86. },
  87. // 下拉列表为空时的提示文案
  88. emptyText: {
  89. type: String,
  90. default: '暂无数据'
  91. },
  92. // 是否为多选
  93. multiple: {
  94. type: Boolean,
  95. default: false
  96. },
  97. // 下拉列表是否需要搜索框
  98. searchable: {
  99. type: Boolean,
  100. default: true
  101. },
  102. // 控制下拉框的宽度
  103. dropdownWidth: {
  104. type: String,
  105. default: '100%'
  106. },
  107. // 控制下拉框的高度
  108. dropdownHeight: {
  109. type: String,
  110. default: '320rpx'
  111. },
  112. // 下拉框显隐回调
  113. onVisibleChange: {
  114. type: Function,
  115. default: null
  116. },
  117. },
  118. emits: ['update:modelValue', 'change', 'loadMore'],
  119. data() {
  120. return {
  121. isOpen: false,
  122. searchText: '',
  123. displayList: [],
  124. page: 0,
  125. hasMore: true
  126. };
  127. },
  128. computed: {
  129. filteredOptions() {
  130. if (!this.searchable || !this.searchText) {
  131. return this.options;
  132. }
  133. const lowerSearch = this.searchText.toLowerCase();
  134. return this.options.filter(op =>
  135. String(op.label).toLowerCase().includes(lowerSearch)
  136. );
  137. },
  138. selectedLabel() {
  139. if (this.multiple) return '';
  140. const found = this.options.find(op => op.value === this.modelValue);
  141. return found ? found.label : '';
  142. },
  143. selectedOptions() {
  144. if (!this.multiple || !Array.isArray(this.modelValue)) return [];
  145. return this.modelValue.map(val => {
  146. const found = this.options.find(op => op.value === val);
  147. return found || { label: val, value: val };
  148. });
  149. },
  150. dropdownStyle() {
  151. const style = {
  152. width: this.dropdownWidth,
  153. maxHeight: this.dropdownHeight,
  154. zIndex: 91 // Ensure dropdown is on top
  155. };
  156. if (this.direction === 'top') {
  157. style.bottom = '100%';
  158. style.top = 'auto';
  159. style.marginBottom = '10rpx';
  160. } else {
  161. style.top = '100%';
  162. style.bottom = 'auto';
  163. style.marginTop = '10rpx';
  164. }
  165. return style;
  166. },
  167. dropdownScrollHeight() {
  168. // 简单估算,如果有搜索框,减去一部分高度
  169. return this.searchable ? 'calc(100% - 80rpx)' : '100%';
  170. }
  171. },
  172. watch: {
  173. isOpen(val) {
  174. if (this.onVisibleChange) {
  175. this.onVisibleChange(val);
  176. }
  177. // if (val) {
  178. // this.resetList();
  179. // } else {
  180. // this.searchText = '';
  181. // }
  182. },
  183. options: {
  184. handler() {
  185. if (this.isOpen) {
  186. this.resetList();
  187. }
  188. },
  189. deep: true
  190. },
  191. searchText() {
  192. this.resetList();
  193. }
  194. },
  195. methods: {
  196. toggleDropdown() {
  197. this.isOpen = !this.isOpen;
  198. },
  199. closeDropdown() {
  200. this.isOpen = false;
  201. },
  202. onSearchInput() {
  203. // 搜索逻辑由 watch searchText 处理
  204. },
  205. resetList() {
  206. this.page = 0;
  207. this.displayList = [];
  208. this.hasMore = true;
  209. this.loadMore();
  210. },
  211. loadMore() {
  212. if (!this.hasMore) return;
  213. const res = getPaginatedList({
  214. page: this.page,
  215. size: 10,
  216. initData: this.filteredOptions,
  217. data: this.displayList
  218. });
  219. if (res.data) {
  220. this.displayList = res.data;
  221. this.page = res.page;
  222. // 如果当前数据量等于总数据量,说明没有更多了
  223. this.hasMore = this.displayList.length < this.filteredOptions.length;
  224. // 触发外部事件,传递当前页码
  225. this.$emit('loadMore', this.page);
  226. } else {
  227. this.hasMore = false;
  228. }
  229. },
  230. onSelect(option) {
  231. if (this.multiple) {
  232. const currentVal = Array.isArray(this.modelValue) ? [...this.modelValue] : [];
  233. const idx = currentVal.indexOf(option.value);
  234. if (idx > -1) {
  235. currentVal.splice(idx, 1);
  236. } else {
  237. currentVal.push(option.value);
  238. }
  239. this.$emit('update:modelValue', currentVal);
  240. this.$emit('change', currentVal);
  241. // 多选时不自动关闭下拉框
  242. } else {
  243. this.$emit('update:modelValue', option.value);
  244. this.$emit('change', option.value);
  245. this.closeDropdown();
  246. }
  247. },
  248. removeOption(option) {
  249. if (!this.multiple) return;
  250. const currentVal = Array.isArray(this.modelValue) ? [...this.modelValue] : [];
  251. const idx = currentVal.indexOf(option.value);
  252. if (idx > -1) {
  253. currentVal.splice(idx, 1);
  254. this.$emit('update:modelValue', currentVal);
  255. this.$emit('change', currentVal);
  256. }
  257. },
  258. clearAll() {
  259. this.$emit('update:modelValue', []);
  260. this.$emit('change', []);
  261. },
  262. isSelected(option) {
  263. if (this.multiple) {
  264. return Array.isArray(this.modelValue) && this.modelValue.includes(option.value);
  265. }
  266. return this.modelValue === option.value;
  267. }
  268. },
  269. // 点击外部关闭下拉框需要页面配合,这里只能尽量在点击自身时处理
  270. // uni-app 中通常使用全屏透明遮罩或页面级点击事件来处理点击外部关闭
  271. // 这里简化处理,仅在组件内部逻辑中控制
  272. }
  273. </script>
  274. <style scoped>
  275. .custom-select-container {
  276. position: relative;
  277. box-sizing: border-box;
  278. }
  279. .select-modal-inputwrap {
  280. position: relative;
  281. background: #f5f7fa;
  282. border-radius: 8rpx;
  283. padding: 0 20rpx;
  284. min-height: 80rpx;
  285. display: flex;
  286. align-items: center;
  287. justify-content: space-between;
  288. }
  289. .select-modal-static {
  290. flex: 1;
  291. font-size: 28rpx;
  292. color: #333;
  293. padding: 16rpx 0;
  294. line-height: 1.4;
  295. /* 防止过长文本溢出,但这可能会影响多选 Tag 的显示,多选 Tag 有自己的样式控制 */
  296. }
  297. .select-input-arrow {
  298. width: 16rpx;
  299. height: 16rpx;
  300. border-right: 3rpx solid #c0c4cc;
  301. border-bottom: 3rpx solid #c0c4cc;
  302. transform: rotate(45deg);
  303. margin-top: -6rpx;
  304. flex-shrink: 0;
  305. margin-left: 10rpx;
  306. }
  307. .select-modal-dropdown {
  308. position: absolute;
  309. left: 0;
  310. right: 0;
  311. background: #fff;
  312. border: 1rpx solid #eef0f4;
  313. border-radius: 12rpx;
  314. box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.08);
  315. overflow-y: hidden;
  316. /* scroll-view handles overflow */
  317. display: flex;
  318. flex-direction: column;
  319. }
  320. .select-dropdown-search-row {
  321. padding: 10rpx;
  322. flex-shrink: 0;
  323. }
  324. .select-dropdown-search-input {
  325. width: 100%;
  326. height: 60rpx;
  327. border: 1rpx solid #eaeef4;
  328. border-radius: 8rpx;
  329. box-sizing: border-box;
  330. padding: 0 16rpx;
  331. font-size: 28rpx;
  332. }
  333. .select-modal-dropdown-item {
  334. padding: 16rpx 20rpx;
  335. font-size: 28rpx;
  336. color: #333;
  337. white-space: nowrap;
  338. overflow: hidden;
  339. text-overflow: ellipsis;
  340. }
  341. .selected {
  342. background: #f0f5ff;
  343. color: #2c69ff;
  344. }
  345. .customer-chip {
  346. display: inline-block;
  347. position: relative;
  348. line-height: 50rpx;
  349. border: 1rpx solid #eaeef4;
  350. border-radius: 8rpx;
  351. padding: 0rpx 44rpx 0rpx 16rpx;
  352. margin-right: 8rpx;
  353. margin-bottom: 8rpx;
  354. font-size: 22rpx;
  355. color: #333;
  356. background: #fff;
  357. }
  358. .customer-chip-close {
  359. position: absolute;
  360. right: 6rpx;
  361. top: 0;
  362. color: #666;
  363. font-size: 28rpx;
  364. padding: 0 8rpx;
  365. border-radius: 6rpx;
  366. }
  367. .customer-clear-all {
  368. position: absolute;
  369. right: 40rpx; /* 调整位置以免遮挡箭头 */
  370. top: 50%;
  371. transform: translateY(-50%);
  372. line-height: 50rpx;
  373. padding: 8rpx 10rpx;
  374. font-size: 40rpx;
  375. color: #999;
  376. z-index: 1;
  377. }
  378. .select-loading-row {
  379. display: flex;
  380. justify-content: center;
  381. padding: 20rpx;
  382. }
  383. .select-loading-wrapper {
  384. width: 40rpx;
  385. height: 40rpx;
  386. animation: rotate 1s linear infinite;
  387. }
  388. .select-loading-icon {
  389. width: 100%;
  390. height: 100%;
  391. }
  392. @keyframes rotate {
  393. from {
  394. transform: rotate(0deg);
  395. }
  396. to {
  397. transform: rotate(360deg);
  398. }
  399. }
  400. </style>