tree-select.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. <template>
  2. <view class="width-100">
  3. <view class="d-flex a-center" style="width:100%">
  4. <view @click="openPopup" class="d-flex px2" style="width:100%">
  5. <view class="input-box flex1">
  6. <view v-if="inputLabel" class="text-color-3">{{ inputLabel }}</view>
  7. <view v-else class="text-color-9">{{ placeholder }}</view>
  8. </view>
  9. <u-icon name="arrow-right"></u-icon>
  10. </view>
  11. </view>
  12. <u-popup mode="bottom" v-model="showPopup" length="auto" :safeAreaInsetBottom="true" :maskCloseAble="true">
  13. <view class="ts-popup">
  14. <view class="ts-header" @touchmove.stop.prevent="">
  15. <view class="ts-btn ts-btn--cancel" hover-class="ts-opacity" :hover-stay-time="150" @tap="handleCancel">取消
  16. </view>
  17. <view class="ts-title">{{ title }}</view>
  18. <view class="ts-btn ts-btn--confirm" hover-class="ts-opacity" :hover-stay-time="150" @tap="handleConfirm">确认
  19. </view>
  20. </view>
  21. <scroll-view scroll-y class="ts-body">
  22. <view v-for="(item, index) in visibleNodes" :key="index" class="ts-item"
  23. :class="{ 'ts-item--active': tempValue === item.node[valueName] }"
  24. :style="{ paddingLeft: item.depth * 40 + 30 + 'rpx' }" @click="selectNode(item)">
  25. <view class="ts-item-label">{{ item.node[labelName] }}</view>
  26. <u-icon v-if="tempValue === item.node[valueName]" name="checkmark" size="28" color="#0082ff"></u-icon>
  27. <view v-if="hasChildren(item.node)" class="ts-arrow" @click.stop="toggleExpand(item.node)">
  28. <u-icon :name="isExpanded(item.node) ? 'arrow-down' : 'arrow-right'" size="26" color="#909399"></u-icon>
  29. </view>
  30. </view>
  31. <view v-if="!visibleNodes.length" class="ts-empty">暂无数据</view>
  32. </scroll-view>
  33. </view>
  34. </u-popup>
  35. </view>
  36. </template>
  37. <script>
  38. import Emitter from '@/uview-ui/libs/util/emitter.js'
  39. /**
  40. * treeSelect 树形选择表单组件
  41. * @description 外观与 my-select 一致的表单选择框,点击后从底部弹出树形选择面板:
  42. * 父节点可通过右侧箭头展开/收起子级,点击任意节点(父/子)即选中,
  43. * 点击“确认”后写回 v-model,并派发 u-form 校验事件。
  44. * @property {String Number} value 选中节点的值(v-model)
  45. * @property {Array} data 树形数据,形如 [{ id, title, children: [...] }]
  46. * @property {Array} list 树形数据(data 的别名,兼容旧用法)
  47. * @property {String} labelName 节点显示文本对应的字段名(默认title)
  48. * @property {String} valueName 节点值对应的字段名(默认id)
  49. * @property {String} childrenName 子节点数组对应的字段名(默认children)
  50. * @property {String} placeholder 未选中时的占位文案(默认请选择)
  51. * @property {String} title 弹出面板顶部标题(默认请选择)
  52. * @property {Boolean} disabled 是否禁用选择(默认false)
  53. * @event {Function} input 确认选中后触发(v-model)
  54. * @event {Function} selected 确认选中后触发,返回 (value, 节点对象)
  55. * @example <tree-select v-model="form.sbCph" :data="sbPositionData" />
  56. */
  57. export default {
  58. name: 'TreeSelect',
  59. mixins: [Emitter],
  60. props: {
  61. // 选中节点的值(v-model)
  62. value: {
  63. type: [String, Number],
  64. default: ''
  65. },
  66. // 树形数据
  67. data: {
  68. type: Array,
  69. default() {
  70. return []
  71. }
  72. },
  73. // 树形数据(data 的别名)
  74. list: {
  75. type: Array,
  76. default() {
  77. return []
  78. }
  79. },
  80. // 显示文本字段名
  81. labelName: {
  82. type: String,
  83. default: 'title'
  84. },
  85. // 值字段名
  86. valueName: {
  87. type: String,
  88. default: 'id'
  89. },
  90. // 子节点字段名
  91. childrenName: {
  92. type: String,
  93. default: 'children'
  94. },
  95. placeholder: {
  96. type: String,
  97. default: '请选择'
  98. },
  99. title: {
  100. type: String,
  101. default: '请选择'
  102. },
  103. disabled: {
  104. type: [String, Number, Boolean],
  105. default: false
  106. }
  107. },
  108. data() {
  109. return {
  110. showPopup: false,
  111. inputLabel: '',
  112. // 弹框内临时选中的值与文本,确认后才写回
  113. tempValue: '',
  114. tempLabel: '',
  115. // 弹框内已展开的节点 value 集合
  116. expandedKeys: []
  117. }
  118. },
  119. computed: {
  120. treeData() {
  121. return this.data && this.data.length ? this.data : this.list
  122. },
  123. // 将树按展开状态拍平为可见节点列表:[{ node, depth }]
  124. visibleNodes() {
  125. const result = []
  126. const walk = (nodes, depth) => {
  127. if (!nodes || !nodes.length) return
  128. for (let i = 0; i < nodes.length; i++) {
  129. const node = nodes[i]
  130. result.push({ node, depth })
  131. if (this.isExpanded(node) && this.hasChildren(node)) {
  132. walk(node[this.childrenName], depth + 1)
  133. }
  134. }
  135. }
  136. walk(this.treeData, 0)
  137. return result
  138. }
  139. },
  140. watch: {
  141. value() {
  142. this.initByValue()
  143. },
  144. treeData() {
  145. this.initByValue()
  146. }
  147. },
  148. created() {
  149. this.$nextTick(() => {
  150. this.initByValue()
  151. })
  152. },
  153. methods: {
  154. openPopup() {
  155. if (this.disabled) {
  156. uni.showToast({ title: '不允许选择', icon: 'none' })
  157. return
  158. }
  159. // 回显当前值:临时选中 + 自动展开其所有父级
  160. this.tempValue = this.value
  161. this.tempLabel = this.inputLabel
  162. const path = this.findPath(this.treeData, this.value) || []
  163. const keys = []
  164. let nodes = this.treeData
  165. for (let i = 0; i < path.length - 1; i++) {
  166. keys.push(nodes[path[i]][this.valueName])
  167. nodes = nodes[path[i]][this.childrenName]
  168. }
  169. this.expandedKeys = keys
  170. this.showPopup = true
  171. },
  172. handleCancel() {
  173. this.showPopup = false
  174. },
  175. handleConfirm() {
  176. const found = this.findNodeByValue(this.treeData, this.tempValue)
  177. if (this.tempValue !== '' && this.tempValue !== null && this.tempValue !== undefined && found) {
  178. this.inputLabel = found[this.labelName]
  179. if (found[this.valueName] + '' !== this.value + '') {
  180. this.$emit('input', found[this.valueName])
  181. this.$emit('selected', found[this.valueName], found)
  182. this.dispatch('u-form-item', 'on-form-change')
  183. }
  184. }
  185. this.showPopup = false
  186. },
  187. // 点击节点:选中;若为父节点则同时展开其子级,方便继续选子选项
  188. selectNode(item) {
  189. this.tempValue = item.node[this.valueName]
  190. this.tempLabel = item.node[this.labelName]
  191. if (this.hasChildren(item.node) && !this.isExpanded(item.node)) {
  192. this.expandedKeys = [...this.expandedKeys, item.node[this.valueName]]
  193. }
  194. },
  195. // 点击箭头:仅展开/收起,不改变选中
  196. toggleExpand(node) {
  197. const key = node[this.valueName]
  198. if (this.isExpanded(node)) {
  199. this.expandedKeys = this.expandedKeys.filter(k => k !== key)
  200. } else {
  201. this.expandedKeys = [...this.expandedKeys, key]
  202. }
  203. },
  204. isExpanded(node) {
  205. return this.expandedKeys.indexOf(node[this.valueName]) !== -1
  206. },
  207. hasChildren(item) {
  208. const children = item[this.childrenName]
  209. return !!(children && children.length)
  210. },
  211. // 递归查找 value 对应节点,返回 { path, node }
  212. findNode(nodes, path = []) {
  213. if (this.value === '' || this.value === null || this.value === undefined) {
  214. return null
  215. }
  216. return this.findNodeByValue(nodes, this.value, path)
  217. },
  218. // 递归查找 value 对应节点的下标路径
  219. findPath(nodes, val, path = []) {
  220. if (!nodes || !nodes.length || val === '' || val === null || val === undefined) {
  221. return null
  222. }
  223. for (let i = 0; i < nodes.length; i++) {
  224. const node = nodes[i]
  225. if (node[this.valueName] == val) {
  226. return [...path, i]
  227. }
  228. const children = node[this.childrenName]
  229. if (children && children.length) {
  230. const found = this.findPath(children, val, [...path, i])
  231. if (found) return found
  232. }
  233. }
  234. return null
  235. },
  236. // 按给定值递归查找节点
  237. findNodeByValue(nodes, val, path = []) {
  238. if (!nodes || !nodes.length || val === '' || val === null || val === undefined) {
  239. return null
  240. }
  241. for (let i = 0; i < nodes.length; i++) {
  242. const node = nodes[i]
  243. if (node[this.valueName] == val) {
  244. return node
  245. }
  246. const children = node[this.childrenName]
  247. if (children && children.length) {
  248. const found = this.findNodeByValue(children, val, [...path, i])
  249. if (found) return found
  250. }
  251. }
  252. return null
  253. },
  254. // 根据外部传入的 value 回显输入框文本
  255. initByValue() {
  256. const found = this.findNode(this.treeData)
  257. this.inputLabel = found ? found[this.labelName] : ''
  258. }
  259. }
  260. }
  261. </script>
  262. <style scoped>
  263. .width-100 {
  264. width: 100%;
  265. }
  266. .ts-popup {
  267. background-color: #fff;
  268. }
  269. .ts-header {
  270. width: 100%;
  271. height: 90rpx;
  272. padding: 0 40rpx;
  273. display: flex;
  274. justify-content: space-between;
  275. align-items: center;
  276. box-sizing: border-box;
  277. font-size: 30rpx;
  278. background: #fff;
  279. position: relative;
  280. }
  281. .ts-header::after {
  282. content: '';
  283. position: absolute;
  284. border-bottom: 1rpx solid #eaeef1;
  285. -webkit-transform: scaleY(0.5);
  286. transform: scaleY(0.5);
  287. bottom: 0;
  288. right: 0;
  289. left: 0;
  290. }
  291. .ts-title {
  292. color: #606266;
  293. }
  294. .ts-btn {
  295. padding: 16rpx;
  296. box-sizing: border-box;
  297. text-align: center;
  298. }
  299. .ts-btn--cancel {
  300. color: #606266;
  301. }
  302. .ts-btn--confirm {
  303. color: #2979ff;
  304. }
  305. .ts-opacity {
  306. opacity: 0.5;
  307. }
  308. .ts-body {
  309. max-height: 600rpx;
  310. padding-bottom: 20rpx;
  311. }
  312. .ts-item {
  313. display: flex;
  314. align-items: center;
  315. justify-content: space-between;
  316. padding-top: 20rpx;
  317. padding-bottom: 20rpx;
  318. padding-right: 30rpx;
  319. font-size: 28rpx;
  320. color: #303133;
  321. }
  322. .ts-item--active {
  323. color: #0082ff;
  324. font-weight: 600;
  325. }
  326. .ts-item-label {
  327. flex: 1;
  328. overflow: hidden;
  329. text-overflow: ellipsis;
  330. white-space: nowrap;
  331. }
  332. .ts-arrow {
  333. padding: 6rpx 10rpx;
  334. }
  335. .ts-empty {
  336. text-align: center;
  337. color: #909399;
  338. font-size: 26rpx;
  339. padding: 40rpx 0;
  340. }
  341. </style>