index.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. <template>
  2. <view class="ss-select-container" :class="{ open: isOpen }" @click.stop="toggleDropdown">
  3. <!-- 显示区域 -->
  4. <view class="ss-select" :class="{ disabled: disabled }">
  5. <text class="select-text" :class="{ placeholder: !selectedValue }">{{ displayText }}</text>
  6. <view class="select-arrow" :class="{ rotate: isOpen }">
  7. <Icon name="icon-xiangxiajiantou" size="32" :color="disabled ? '#ccc' : '#999'"/>
  8. </view>
  9. </view>
  10. <!-- 选项列表 -->
  11. <view class="ss-options" v-show="isOpen">
  12. <!-- 加载状态 -->
  13. <view
  14. v-if="finalLoading"
  15. class="option-item loading-item"
  16. >
  17. <text class="loading-text">加载中...</text>
  18. </view>
  19. <!-- 无选项 -->
  20. <view
  21. v-else-if="optionsList.length === 0"
  22. class="option-item no-options"
  23. >
  24. 无选项
  25. </view>
  26. <!-- 选项列表 -->
  27. <view
  28. v-else
  29. v-for="(option, index) in optionsList"
  30. :key="index"
  31. class="option-item"
  32. :class="{ selected: option[props.mapping.value] === selectedValue }"
  33. @click.stop="selectOption(option)"
  34. >
  35. {{ option[props.mapping.text] }}
  36. </view>
  37. </view>
  38. </view>
  39. </template>
  40. <script setup>
  41. import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
  42. import request from '@/utils/request'
  43. import Icon from '@/components/icon/index.vue';
  44. // Props 定义
  45. const props = defineProps({
  46. // 选项数组
  47. options: {
  48. type: Array,
  49. default: () => []
  50. },
  51. // 字段映射
  52. mapping: {
  53. type: Object,
  54. default: () => ({ text: 'n', value: 'v' })
  55. },
  56. // 默认值
  57. modelValue: {
  58. type: [String, Number],
  59. default: ''
  60. },
  61. // 占位符
  62. placeholder: {
  63. type: String,
  64. default: '请选择'
  65. },
  66. // 校验配置
  67. validation: {
  68. type: Object,
  69. default: () => ({ enable: false, message: '' })
  70. },
  71. // 是否禁用
  72. disabled: {
  73. type: Boolean,
  74. default: false
  75. },
  76. // 是否支持搜索
  77. searchable: {
  78. type: Boolean,
  79. default: false
  80. },
  81. // 是否支持清空
  82. clearable: {
  83. type: Boolean,
  84. default: false
  85. },
  86. // 加载状态
  87. loading: {
  88. type: Boolean,
  89. default: false
  90. },
  91. // 宽度设置
  92. width: {
  93. type: String,
  94. default: '100%'
  95. },
  96. minWidth:{
  97. type:String,
  98. default:'unset'
  99. },
  100. // 对齐PC端 ss-objp:支持组件内部按 codebook 拉取下拉选项
  101. cb: {
  102. type: String,
  103. default: ''
  104. },
  105. url: {
  106. type: String,
  107. default: '/service?ssServ=loadObjpOpt&objectpickerdropdown1=1'
  108. },
  109. inp: {
  110. type: [Boolean, String],
  111. default: false
  112. },
  113. filter: {
  114. type: [Object, String],
  115. default: null
  116. },
  117. autoSelectFirst: {
  118. type: Boolean,
  119. default: false
  120. }
  121. })
  122. // Emits 定义
  123. const emit = defineEmits(['update:modelValue', 'change', 'search', 'clear'])
  124. // 响应式数据
  125. const isOpen = ref(false)
  126. const selectedValue = ref(props.modelValue)
  127. const searchKeyword = ref('')
  128. const remoteOptions = ref([])
  129. const internalLoading = ref(false)
  130. const parseFilterObj = () => {
  131. if (!props.filter) return {}
  132. if (typeof props.filter === 'object') return props.filter
  133. if (typeof props.filter === 'string') {
  134. try {
  135. const obj = JSON.parse(props.filter)
  136. return obj && typeof obj === 'object' ? obj : {}
  137. } catch (_) {
  138. return {}
  139. }
  140. }
  141. return {}
  142. }
  143. const normalizeResultToOptions = (respData) => {
  144. const raw = respData || {}
  145. if (Array.isArray(raw.resultList)) {
  146. return raw.resultList.map((it) => {
  147. if (it && typeof it === 'object') return it
  148. return { n: String(it || ''), v: String(it || '') }
  149. })
  150. }
  151. if (raw.result && typeof raw.result === 'object') {
  152. return Object.keys(raw.result).map((key) => ({
  153. n: raw.result[key],
  154. v: key
  155. }))
  156. }
  157. if (Array.isArray(raw.objectList)) {
  158. return raw.objectList.map((it) => {
  159. if (it && typeof it === 'object') return it
  160. return { n: String(it || ''), v: String(it || '') }
  161. })
  162. }
  163. return []
  164. }
  165. const maybeAutoSelectFirst = (opts) => {
  166. if (!props.autoSelectFirst) return
  167. if (!Array.isArray(opts) || opts.length === 0) return
  168. if (selectedValue.value !== undefined && selectedValue.value !== null && selectedValue.value !== '') return
  169. const first = opts[0]
  170. if (!first || typeof first !== 'object') return
  171. const value = first[props.mapping.value]
  172. if (value === undefined || value === null || value === '') return
  173. selectedValue.value = value
  174. emit('update:modelValue', value)
  175. emit('change', value, first)
  176. }
  177. const needRemoteData = computed(() => !!String(props.cb || '').trim())
  178. const loadRemoteOptions = async () => {
  179. if (!needRemoteData.value) return
  180. internalLoading.value = true
  181. try {
  182. const data = {
  183. objectpickerparam: JSON.stringify({
  184. input: String(props.inp === true || props.inp === 'true'),
  185. codebook: String(props.cb || ''),
  186. ...parseFilterObj()
  187. }),
  188. objectpickertype: 1,
  189. objectpickersearchAll: 1
  190. }
  191. const resp = await request.post(
  192. props.url || '/service?ssServ=loadObjpOpt&objectpickerdropdown1=1',
  193. data,
  194. {
  195. loading: false,
  196. formData: true,
  197. }
  198. )
  199. const opts = normalizeResultToOptions(resp?.data)
  200. remoteOptions.value = opts
  201. maybeAutoSelectFirst(opts)
  202. } catch (error) {
  203. remoteOptions.value = []
  204. console.error('[SsSelect] loadRemoteOptions failed', props.cb, error)
  205. } finally {
  206. internalLoading.value = false
  207. }
  208. }
  209. // 计算属性
  210. const optionsList = computed(() => {
  211. if (Array.isArray(props.options) && props.options.length > 0) return props.options
  212. if (needRemoteData.value) return remoteOptions.value
  213. return []
  214. })
  215. const finalLoading = computed(() => !!props.loading || internalLoading.value)
  216. const displayText = computed(() => {
  217. if (!selectedValue.value) return props.placeholder
  218. const selectedOption = optionsList.value.find(
  219. option => option[props.mapping.value] === selectedValue.value
  220. )
  221. return selectedOption ? selectedOption[props.mapping.text] : props.placeholder
  222. })
  223. // 方法
  224. const toggleDropdown = () => {
  225. if (props.disabled) return
  226. if (!isOpen.value) {
  227. // 关闭其他下拉框
  228. uni.$emit('closeAllSelects')
  229. }
  230. isOpen.value = !isOpen.value
  231. }
  232. const selectOption = (option) => {
  233. const value = option[props.mapping.value]
  234. selectedValue.value = value
  235. isOpen.value = false
  236. // 触发事件
  237. emit('update:modelValue', value)
  238. emit('change', value, option)
  239. }
  240. const closeDropdown = () => {
  241. isOpen.value = false
  242. }
  243. // 设置值的方法(供外部调用)
  244. const setValue = (value) => {
  245. if (!value) {
  246. selectedValue.value = ''
  247. emit('update:modelValue', '')
  248. return true
  249. }
  250. const option = optionsList.value.find(
  251. opt => opt[props.mapping.value] === value
  252. )
  253. if (option) {
  254. selectedValue.value = value
  255. emit('update:modelValue', value)
  256. emit('change', value, option)
  257. return true
  258. }
  259. return false
  260. }
  261. // 监听外部值变化
  262. watch(() => props.modelValue, (newValue) => {
  263. selectedValue.value = newValue
  264. })
  265. // 监听全局关闭事件
  266. const handleGlobalClose = () => {
  267. closeDropdown()
  268. }
  269. onMounted(() => {
  270. uni.$on('closeAllSelects', handleGlobalClose)
  271. // 点击页面其他地方关闭下拉框
  272. uni.$on('pageClick', closeDropdown)
  273. loadRemoteOptions()
  274. })
  275. watch(
  276. () => [props.cb, props.url, props.filter],
  277. () => {
  278. if (!needRemoteData.value) return
  279. loadRemoteOptions()
  280. }
  281. )
  282. onUnmounted(() => {
  283. uni.$off('closeAllSelects', handleGlobalClose)
  284. uni.$off('pageClick', closeDropdown)
  285. })
  286. // 暴露方法给父组件
  287. defineExpose({
  288. setValue,
  289. getValue: () => selectedValue.value,
  290. getSelectedOption: () => {
  291. return optionsList.value.find(
  292. option => option[props.mapping.value] === selectedValue.value
  293. )
  294. },
  295. reloadOptions: loadRemoteOptions
  296. })
  297. </script>
  298. <style lang="scss" scoped>
  299. /* ss暂用下拉框样式 */
  300. .ss-select-container {
  301. position: relative;
  302. width: v-bind(width);
  303. min-width: v-bind(minWidth);
  304. font-size: 32rpx;
  305. }
  306. .ss-select {
  307. padding: 10rpx 0;
  308. border-radius: 10rpx;
  309. cursor: pointer;
  310. position: relative;
  311. display: flex;
  312. align-items: center;
  313. justify-content: space-between;
  314. min-height: 28rpx;
  315. box-sizing: border-box;
  316. &.disabled {
  317. opacity: 0.6;
  318. cursor: not-allowed;
  319. .select-text {
  320. color: #ccc;
  321. }
  322. }
  323. }
  324. .select-text {
  325. flex: 1;
  326. color: #333;
  327. &.placeholder {
  328. color: #999;
  329. }
  330. }
  331. .select-arrow {
  332. display: flex;
  333. align-items: center;
  334. transition: transform 0.3s ease;
  335. margin-left: 20rpx;
  336. &.rotate {
  337. transform: rotate(180deg);
  338. }
  339. }
  340. .ss-options {
  341. display: none;
  342. position: absolute;
  343. top: 100%;
  344. left: 0;
  345. width: 100%;
  346. background-color: #393D51;
  347. z-index: 1000;
  348. color: #fff;
  349. border: 2rpx solid #393D51;
  350. box-sizing: border-box;
  351. border-radius: 10rpx;
  352. overflow: hidden;
  353. max-height: 600rpx;
  354. overflow: auto;
  355. }
  356. .ss-select-container.open .ss-options {
  357. display: block;
  358. }
  359. .option-item {
  360. padding: 20rpx 20rpx 20rpx 46rpx;
  361. cursor: pointer;
  362. position: relative;
  363. &::after {
  364. content: "";
  365. position: absolute;
  366. bottom: 0%;
  367. left: 50%;
  368. transform: translateX(-50%);
  369. width: 80%;
  370. height: 2rpx;
  371. background-color: #303445;
  372. }
  373. &:last-child::after,
  374. &:hover::after,
  375. &.selected::after {
  376. display: none;
  377. }
  378. &:hover {
  379. background-color: #fff;
  380. color: #393D51;
  381. }
  382. &.selected {
  383. background-color: #fff;
  384. color: #393D51;
  385. &::before {
  386. content: "";
  387. background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="%23393D51"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>') no-repeat center;
  388. position: absolute;
  389. width: 30rpx;
  390. height: 100%;
  391. left: 10rpx;
  392. top: 0;
  393. color: #393D51;
  394. }
  395. }
  396. &.no-options {
  397. text-align: center;
  398. color: #999;
  399. cursor: default;
  400. &:hover {
  401. background-color: #393D51;
  402. color: #999;
  403. }
  404. }
  405. &.loading-item {
  406. text-align: center;
  407. color: #999;
  408. cursor: default;
  409. &:hover {
  410. background-color: #393D51;
  411. color: #999;
  412. }
  413. .loading-text {
  414. animation: loading-pulse 1.5s ease-in-out infinite;
  415. }
  416. }
  417. }
  418. @keyframes loading-pulse {
  419. 0%, 100% { opacity: 0.6; }
  420. 50% { opacity: 1; }
  421. }
  422. </style>