index.vue 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. <template>
  2. <view class="custom-picker-overlay" v-if="visible" @tap="handleOverlayTap">
  3. <view class="custom-picker-container" @tap.stop="">
  4. <!-- 头部 -->
  5. <view class="picker-header">
  6. <view class="picker-cancel" @tap="handleCancel">取消</view>
  7. <view class="picker-title">{{ title || '请选择' }}</view>
  8. <view class="picker-confirm" @tap="handleConfirm">确定</view>
  9. </view>
  10. <!-- 搜索框 -->
  11. <view class="search-container" v-if="searchable">
  12. <view class="search-box">
  13. <text class="iconfont icon-sousuo search-icon"></text>
  14. <input
  15. class="search-input"
  16. v-model="searchValue"
  17. placeholder="搜索..."
  18. placeholder-class="search-placeholder"
  19. @input="handleSearch"
  20. />
  21. <text class="clear-icon" v-if="searchValue" @tap="clearSearch">×</text>
  22. </view>
  23. </view>
  24. <!-- 选项列表 -->
  25. <scroll-view class="options-container" scroll-y="true">
  26. <view class="options-list">
  27. <view
  28. class="option-item"
  29. v-for="(item, index) in filteredOptions"
  30. :key="index"
  31. :class="{ 'selected': isSelected(item) }"
  32. @tap="handleSelect(item, index)"
  33. >
  34. <view class="option-content">
  35. <view class="option-text" v-html="highlightText(getDisplayText(item))"></view>
  36. <view class="option-check" v-if="isSelected(item)">✓</view>
  37. </view>
  38. </view>
  39. <!-- 无数据提示 -->
  40. <view class="no-data" v-if="filteredOptions.length === 0">
  41. <text>{{ searchValue ? '暂无匹配数据' : '暂无数据' }}</text>
  42. </view>
  43. </view>
  44. </scroll-view>
  45. </view>
  46. </view>
  47. </template>
  48. <script>
  49. export default {
  50. name: 'CustomPicker',
  51. props: {
  52. // 是否显示选择器
  53. visible: {
  54. type: Boolean,
  55. default: false
  56. },
  57. // 选择器标题
  58. title: {
  59. type: String,
  60. default: '请选择'
  61. },
  62. // 选项数据
  63. options: {
  64. type: Array,
  65. default: () => []
  66. },
  67. // 显示字段名
  68. displayKey: {
  69. type: String,
  70. default: 'label'
  71. },
  72. // 值字段名
  73. valueKey: {
  74. type: String,
  75. default: 'value'
  76. },
  77. // 当前选中值
  78. value: {
  79. type: [String, Number, Object],
  80. default: ''
  81. },
  82. // 是否可搜索
  83. searchable: {
  84. type: Boolean,
  85. default: true
  86. },
  87. // 是否多选
  88. multiple: {
  89. type: Boolean,
  90. default: false
  91. },
  92. // 自定义过滤函数
  93. filterMethod: {
  94. type: Function,
  95. default: null
  96. }
  97. },
  98. data() {
  99. return {
  100. searchValue: '', // 搜索关键词
  101. selectedValue: null, // 当前选中值
  102. selectedValues: [] // 多选时的选中值数组
  103. }
  104. },
  105. computed: {
  106. // 过滤后的选项
  107. filteredOptions() {
  108. if (!this.searchValue) {
  109. return this.options
  110. }
  111. // 使用自定义过滤方法
  112. if (this.filterMethod) {
  113. return this.options.filter(item => this.filterMethod(this.searchValue, item))
  114. }
  115. // 默认过滤逻辑
  116. const keyword = this.searchValue.toLowerCase()
  117. return this.options.filter(item => {
  118. const text = this.getDisplayText(item).toLowerCase()
  119. return text.includes(keyword)
  120. })
  121. }
  122. },
  123. watch: {
  124. visible(newVal) {
  125. if (newVal) {
  126. this.initSelectedValue()
  127. } else {
  128. this.searchValue = ''
  129. }
  130. },
  131. value: {
  132. handler(newVal) {
  133. this.initSelectedValue()
  134. },
  135. immediate: true
  136. }
  137. },
  138. methods: {
  139. // 初始化选中值
  140. initSelectedValue() {
  141. if (this.multiple) {
  142. this.selectedValues = Array.isArray(this.value) ? [...this.value] : []
  143. } else {
  144. this.selectedValue = this.value
  145. }
  146. },
  147. // 获取显示文本
  148. getDisplayText(item) {
  149. if (typeof item === 'string') {
  150. return item
  151. }
  152. return item[this.displayKey] || item.label || item.name || String(item)
  153. },
  154. // 获取项目值
  155. getItemValue(item) {
  156. if (typeof item === 'string' || typeof item === 'number') {
  157. return item
  158. }
  159. return item[this.valueKey] || item.value || item
  160. },
  161. // 判断是否选中
  162. isSelected(item) {
  163. const itemValue = this.getItemValue(item)
  164. if (this.multiple) {
  165. return this.selectedValues.some(val => {
  166. const compareValue = this.getItemValue(val)
  167. return compareValue === itemValue
  168. })
  169. } else {
  170. const compareValue = this.getItemValue(this.selectedValue)
  171. return compareValue === itemValue
  172. }
  173. },
  174. // 处理选择
  175. handleSelect(item, index) {
  176. if (this.multiple) {
  177. const itemValue = this.getItemValue(item)
  178. const existIndex = this.selectedValues.findIndex(val => {
  179. const compareValue = this.getItemValue(val)
  180. return compareValue === itemValue
  181. })
  182. if (existIndex > -1) {
  183. // 取消选择
  184. this.selectedValues.splice(existIndex, 1)
  185. } else {
  186. // 添加选择
  187. this.selectedValues.push(item)
  188. }
  189. } else {
  190. this.selectedValue = item
  191. // 单选模式下,选择后自动确认
  192. this.$nextTick(() => {
  193. this.handleConfirm()
  194. })
  195. }
  196. },
  197. // 搜索处理
  198. handleSearch(e) {
  199. this.searchValue = e.detail.value
  200. },
  201. // 清空搜索
  202. clearSearch() {
  203. this.searchValue = ''
  204. },
  205. // 高亮搜索关键词
  206. highlightText(text) {
  207. if (!this.searchValue) {
  208. return text
  209. }
  210. const keyword = this.searchValue
  211. const regex = new RegExp(`(${keyword})`, 'gi')
  212. return text.replace(regex, '<span class="highlight">$1</span>')
  213. },
  214. // 确认选择
  215. handleConfirm() {
  216. let result
  217. if (this.multiple) {
  218. result = {
  219. selectedItems: [...this.selectedValues],
  220. selectedValues: this.selectedValues.map(item => this.getItemValue(item))
  221. }
  222. } else {
  223. result = {
  224. selectedItem: this.selectedValue,
  225. selectedValue: this.getItemValue(this.selectedValue),
  226. selectedIndex: this.options.findIndex(option => {
  227. return this.getItemValue(option) === this.getItemValue(this.selectedValue)
  228. })
  229. }
  230. }
  231. this.$emit('confirm', result)
  232. this.$emit('change', result)
  233. this.hide()
  234. },
  235. // 取消选择
  236. handleCancel() {
  237. this.$emit('cancel')
  238. this.hide()
  239. },
  240. // 隐藏选择器
  241. hide() {
  242. this.$emit('update:visible', false)
  243. },
  244. // 处理遮罩点击
  245. handleOverlayTap() {
  246. this.handleCancel()
  247. }
  248. }
  249. }
  250. </script>
  251. <style lang="scss" scoped>
  252. .custom-picker-overlay {
  253. position: fixed;
  254. top: 0;
  255. left: 0;
  256. right: 0;
  257. bottom: 0;
  258. background-color: rgba(0, 0, 0, 0.5);
  259. z-index: 9999;
  260. display: flex;
  261. align-items: flex-end;
  262. }
  263. .custom-picker-container {
  264. width: 100%;
  265. max-height: 80vh;
  266. background-color: #fff;
  267. border-radius: 24rpx 24rpx 0 0;
  268. display: flex;
  269. flex-direction: column;
  270. }
  271. .picker-header {
  272. display: flex;
  273. align-items: center;
  274. justify-content: space-between;
  275. padding: 30rpx;
  276. border-bottom: 1rpx solid #eee;
  277. .picker-cancel {
  278. font-size: 32rpx;
  279. color: #999;
  280. }
  281. .picker-title {
  282. font-size: 32rpx;
  283. font-weight: 600;
  284. color: #333;
  285. }
  286. .picker-confirm {
  287. font-size: 32rpx;
  288. color: #12b792;
  289. font-weight: 600;
  290. }
  291. }
  292. .search-container {
  293. padding: 20rpx 30rpx;
  294. border-bottom: 1rpx solid #f5f5f5;
  295. }
  296. .search-box {
  297. position: relative;
  298. display: flex;
  299. align-items: center;
  300. background-color: #f8f8f8;
  301. border-radius: 24rpx;
  302. padding: 0 30rpx;
  303. height: 72rpx;
  304. .search-icon {
  305. font-size: 28rpx;
  306. color: #999;
  307. margin-right: 20rpx;
  308. }
  309. .search-input {
  310. flex: 1;
  311. font-size: 28rpx;
  312. color: #333;
  313. height: 100%;
  314. }
  315. .search-placeholder {
  316. color: #999;
  317. font-size: 28rpx;
  318. }
  319. .clear-icon {
  320. font-size: 36rpx;
  321. color: #ccc;
  322. width: 40rpx;
  323. height: 40rpx;
  324. border-radius: 50%;
  325. background-color: #ddd;
  326. display: flex;
  327. align-items: center;
  328. justify-content: center;
  329. margin-left: 20rpx;
  330. }
  331. }
  332. .options-container {
  333. flex: 1;
  334. max-height: 600rpx;
  335. }
  336. .options-list {
  337. padding: 0 30rpx;
  338. }
  339. .option-item {
  340. padding: 30rpx 0;
  341. border-bottom: 1rpx solid #f5f5f5;
  342. &:last-child {
  343. border-bottom: none;
  344. }
  345. &.selected {
  346. .option-content {
  347. .option-text {
  348. color: #12b792;
  349. }
  350. .option-check {
  351. color: #12b792;
  352. }
  353. }
  354. }
  355. }
  356. .option-content {
  357. display: flex;
  358. align-items: center;
  359. justify-content: space-between;
  360. }
  361. .option-text {
  362. font-size: 28rpx;
  363. color: #333;
  364. flex: 1;
  365. :deep(.highlight) {
  366. background-color: #fff2e8;
  367. color: #ff6b35;
  368. font-weight: 600;
  369. }
  370. }
  371. .option-check {
  372. font-size: 32rpx;
  373. font-weight: 600;
  374. margin-left: 20rpx;
  375. }
  376. .no-data {
  377. padding: 100rpx 0;
  378. text-align: center;
  379. text {
  380. font-size: 28rpx;
  381. color: #999;
  382. }
  383. }
  384. // 动画效果
  385. .custom-picker-container {
  386. animation: slideUp 0.3s ease-out;
  387. }
  388. @keyframes slideUp {
  389. from {
  390. transform: translateY(100%);
  391. }
  392. to {
  393. transform: translateY(0);
  394. }
  395. }
  396. </style>