123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549 |
- <template>
- <view class="u-tree">
- <view class="u-tree__nodes">
- <view
- v-for="(node, index) in visibleNodes"
- :key="index"
- class="u-tree__node-wrapper"
- :style="[nodeWrapperStyle(node)]"
- >
- <view
- class="u-tree__switcher"
- v-if="showSwitcher"
- @tap.stop="onToggle(node)"
- >
- <slot
- name="switcher"
- :hide="!(hasChildren(node) || (!!loadNode && !isLeaf(node)))"
- :loading="isLoading(node)"
- :expanded="isExpanded(node)"
- >
- <u-loading-icon
- v-if="isLoading(node)"
- mode="circle"
- :color="loadingColor"
- :size="12"
- />
- <u-icon
- v-else-if="hasChildren(node) || (!!loadNode && !isLeaf(node))"
- :name="isExpanded(node) ? collapseIcon : expandIcon"
- :size="switcherSize"
- :color="switcherColor"
- />
- </slot>
- </view>
- <u-checkbox
- v-if="checkable"
- :size="16"
- :checked="isChecked(node)"
- :indeterminate="isIndeterminate(node)"
- :activeColor="checkedColor"
- @change="handleCheck(node)"
- ></u-checkbox>
- <view
- class="u-tree__content"
- :style="[contentStyle(node)]"
- @tap.stop="onToggle(node)"
- >
- <slot name="content" :node="node">
- <text class="u-tree__label" :style="[labelStyle(node)]">
- {{ node[labelField] }}
- </text>
- </slot>
- </view>
- </view>
- </view>
- </view>
- </template>
- <script>
- import props from './props.js';
- import mixin from '../../libs/mixin/mixin';
- import mpMixin from '../../libs/mixin/mpMixin';
- /**
- * tree树形组件
- * @description 树形结构,支持选择、级联选择、异步加载、搜索过滤、插槽自定义。
- * @tutorial https://uview.d3u.cn/components/tree.html
- * @property {Array} data 数据源
- * @property {String} keyField 节点key
- * @property {String} labelField 节点label
- * @property {String} childrenField 子节点
- * @property {String} isLeafField 是否叶子节点
- * @property {String} disabledField 是否禁用
- * @property {Array} defaultCheckedKeys 默认选中
- * @property {Array} defaultExpandedKeys 默认展开
- * @property {Array} checkedKeys 受控的选中项
- * @property {Array} expandedKeys 受控的展开项
- * @property {Boolean} checkable 是否可选择
- * @property {Boolean} selectable 是否可选择
- * @property {Boolean} cascade 是否级联
- * @property {Boolean} expandOnClick 是否允许点击节点展开/收缩
- * @property {Boolean} checkOnClick 是否允许点击节点勾选/取消勾选
- * @property {Function} loadNode 异步加载节点数据
- * @property {Boolean} allowCheckingNotLoaded 是否允许勾选未加载的节点
- * @property {Boolean} showIrrelevantNodes 是否显示搜索无关的节点
- * @property {Boolean} showSwitcher 是否显示展开/收缩按钮
- * @property {String} expandIcon 展开图标
- * @property {String} collapseIcon 收缩图标
- * @property {String} loadingColor loading的颜色
- * @property {String} checkedColor checkebox选中颜色
- * @property {Boolean} rotatableSwitcher 是否可旋转展开/收缩按钮
- * @property {String} highlightBgColor 高亮背景颜色
- * @property {String} selectedBgColor 选中背景颜色
- * @property {Number} switcherSize 展开/收缩按钮大小
- * @property {String} switcherColor 展开/收缩按钮颜色
- * @property {String} indentWidth 缩进宽度
- * @property {String} pattern 搜索词
- * @property {String} highlightBgColor 高亮背景颜色
- * @property {String} selectedBgColor 选中背景颜色
- * @property {Number} switcherSize 展开/收缩按钮大小
- * @property {String} switcherColor 展开/收缩按钮颜色
- *
- * @event {event} click 点击节点
- * @event {event} checked 勾选节点
- * @event {event} expanded 展开节点
- * @event {event} update:checked-keys 更新选中项
- * @event {event} update:expanded-keys 更新展开项
- *
- * @slot {slot} switcher 展开/收缩按钮
- * @slot {slot} content 节点内容
- *
- * @example
- * <u-tree :data="data" @click="click" @checked="checked" @expanded="expanded" @update:checked-keys="updateCheckedKeys" @update:expanded-keys="updateExpandedKeys"></u-tree>
- */
- export default {
- name: 'u-tree',
- mixins: [mpMixin, mixin, props],
- data() {
- return {
- innerCheckedKeys: this.defaultCheckedKeys
- ? [...this.defaultCheckedKeys]
- : [],
- innerExpandedKeys: this.defaultExpandedKeys
- ? [...this.defaultExpandedKeys]
- : [],
- selectedKey: null,
- loadingMap: {},
- };
- },
- computed: {
- visibleNodes() {
- const result = [];
- const filter = (node) => {
- if (!this.pattern) return true;
- const label = node[this.labelField] + '';
- return label
- .toLowerCase()
- .includes(this.pattern.toLowerCase());
- };
- const traverse = (nodes, level, parentMatched) => {
- if (!nodes || nodes.length === 0) return;
- for (const n of nodes) {
- const matched = filter(n);
- const shouldShow = this.showIrrelevantNodes
- ? true
- : matched ||
- parentMatched ||
- this.hasDescendantMatch(n);
- if (shouldShow) {
- result.push({
- ...n,
- __level: level,
- __matched: matched,
- });
- if (this.isExpanded(n)) {
- traverse(
- n[this.childrenField],
- level + 1,
- parentMatched || matched,
- );
- }
- }
- }
- };
- traverse(this.data, 0, false);
- return result;
- },
- },
- watch: {
- data: {
- handler() {
- this.buildNodeIndex();
- },
- deep: true,
- immediate: true,
- },
- pattern: {
- handler() {
- this.expandAncestorsForPattern();
- },
- },
- },
- // #ifdef VUE3
- emits: [
- 'click',
- 'checked',
- 'expanded',
- 'update:checked-keys',
- 'update:expanded-keys',
- ],
- // #endif
- methods: {
- keyOf(node) {
- return node[this.keyField];
- },
- // 受控/非受控展开、勾选 keys 统一获取
- getEffectiveExpandedKeys() {
- return this.expandedKeys ? this.expandedKeys : this.innerExpandedKeys;
- },
- getEffectiveCheckedKeys() {
- return this.checkedKeys ? this.checkedKeys : this.innerCheckedKeys;
- },
- ensureNodeIndexesBuilt() {
- if (!this._nodeIndex || !this._parentIndex) this.buildNodeIndex();
- },
- // 通用索引构建器:从任意起点增量构建 key->node 与 key->parentKey
- indexNodes(startNodes, parentKey) {
- const stack = Array.isArray(startNodes)
- ? startNodes.map((n) => ({ node: n, parentKey }))
- : [];
- while (stack.length) {
- const { node: current, parentKey: currentParentKey } = stack.pop();
- if (!current) continue;
- const currentNodeKey = this.keyOf(current);
- if (currentNodeKey) {
- this._nodeIndex[currentNodeKey] = current;
- if (currentParentKey) this._parentIndex[currentNodeKey] = currentParentKey;
- }
- const children = current[this.childrenField];
- if (Array.isArray(children) && children.length) {
- for (let i = 0; i < children.length; i++) {
- stack.push({ node: children[i], parentKey: currentNodeKey });
- }
- }
- }
- },
- buildNodeIndex() {
- this._nodeIndex = Object.create(null);
- this._parentIndex = Object.create(null);
- this.indexNodes(this.data, null);
- },
-
- indexSubtree(node) {
- if (!node) return;
- const children = node[this.childrenField];
- if (!Array.isArray(children) || children.length === 0) return;
- const parentKey = this.keyOf(node);
- this.indexNodes(children, parentKey);
- },
- // 根据搜索词,自动展开所有命中的子节点的祖先
- expandAncestorsForPattern() {
- const p = (this.pattern || '').toLowerCase();
- if (!p) {
- if (this._preSearchExpandedKeys) {
- this.setExpandedKeys(this._preSearchExpandedKeys);
- this._preSearchExpandedKeys = null;
- }
- return;
- }
- this.ensureNodeIndexesBuilt();
- if (!this._preSearchExpandedKeys) {
- const current = this.getEffectiveExpandedKeys();
- this._preSearchExpandedKeys = Array.isArray(current) ? current.slice() : [];
- }
- const next = new Set(this._preSearchExpandedKeys || []);
- for (const nodeKey in this._nodeIndex) {
- const nodeRef = this._nodeIndex[nodeKey];
- const label = (nodeRef[this.labelField] + '').toLowerCase();
- if (label.includes(p)) {
- let parentKey = this._parentIndex ? this._parentIndex[nodeKey] : null;
- while (parentKey) {
- next.add(parentKey);
- parentKey = this._parentIndex[parentKey];
- }
- }
- }
- this.setExpandedKeys(Array.from(next));
- },
- childrenOf(node) {
- return node[this.childrenField] || [];
- },
- hasChildren(node) {
- const children = node[this.childrenField];
- return children && children.length > 0;
- },
- isLeaf(node) {
- const flag = node && node[this.isLeafField];
- return flag === true;
- },
- isExpanded(node) {
- const nodeKey = this.keyOf(node);
- return new Set(this.getEffectiveExpandedKeys()).has(nodeKey);
- },
- isChecked(node) {
- const nodeKey = this.keyOf(node);
- return new Set(this.getEffectiveCheckedKeys()).has(nodeKey);
- },
- isIndeterminate(node) {
- if (!this.cascade) return false;
- const children = this.childrenOf(node);
- if (!children || children.length === 0) return false;
- let checkedCount = 0;
- let indeterminateCount = 0;
- for (const child of children) {
- if (this.isChecked(child)) {
- checkedCount++;
- } else if (this.isIndeterminate(child)) {
- indeterminateCount++;
- }
- }
- // 如果有选中的子节点但不是全部选中,或者有不确定状态的子节点,则当前节点为不确定状态
- return (
- (checkedCount > 0 && checkedCount < children.length) ||
- indeterminateCount > 0
- );
- },
- isSelected(node) {
- return this.selectable && this.selectedKey === this.keyOf(node);
- },
- contentStyle(node) {
- const style = {};
- if (this.isSelected(node) && this.selectedBgColor) {
- style.background = this.selectedBgColor;
- }
- return style;
- },
- labelStyle(node) {
- const style = {};
- if (this.pattern && node.__matched && this.highlightBgColor) {
- style.backgroundColor = this.highlightBgColor;
- }
- return style;
- },
- nodeWrapperStyle(node) {
- return {
- paddingLeft: uni.$u.addUnit(
- (node.__level || 0) * Number(this.indentWidth),
- ),
- };
- },
- onToggle(node) {
- const nodeKey = this.keyOf(node);
- this.selectedKey = nodeKey;
- if (!this.hasChildren(node) && (!this.loadNode || this.isLeaf(node))) return;
-
- const expanded = this.isExpanded(node);
- let next = new Set(this.getEffectiveExpandedKeys());
- if (expanded){
- next.delete(nodeKey);
- } else {
- next.add(nodeKey);
- }
-
- this.setExpandedKeys(Array.from(next));
- if (!expanded && this.loadNode && !this.hasChildren(node) && !this.isLeaf(node)) {
- this.setLoading(node, true);
- this.ensureNodeIndexesBuilt();
- const sourceNode = this._nodeIndex[nodeKey];
- Promise.resolve(this.loadNode(sourceNode)).finally(() => {
- this.indexSubtree(sourceNode);
- this.setLoading(node, false);
- });
- }
- },
- handleCheck(node) {
- if (!!node[this.disabledField]) return;
- const nodeKey = this.keyOf(node);
- let current = new Set(this.getEffectiveCheckedKeys());
- const checked = current.has(nodeKey);
- if (checked) {
- current.delete(nodeKey);
- } else {
- current.add(nodeKey);
- }
- if (this.cascade) {
- // 级联对子孙生效
- const affectDescendants = (n, value) => {
- const descendantKey = this.keyOf(n);
- if (value) {
- current.add(descendantKey);
- } else {
- current.delete(descendantKey);
- }
- for (const childNode of this.childrenOf(n)) {
- affectDescendants(childNode, value);
- }
- };
- // 级联对祖先生效
- const affectAncestors = (n) => {
- const parent = this.findParent(
- this.data,
- this.keyOf(n),
- );
- if (!parent) return;
- const parentKey = this.keyOf(parent);
- const siblings = this.childrenOf(parent);
- let allChecked = true;
- let anyChecked = false;
- for (const sibling of siblings) {
- const siblingKey = this.keyOf(sibling);
- if (current.has(siblingKey)) {
- anyChecked = true;
- } else {
- allChecked = false;
- }
- }
- if (allChecked) {
- current.add(parentKey);
- } else {
- current.delete(parentKey);
- }
- // 递归处理上级父节点
- affectAncestors(parent);
- };
- // 先处理子孙节点
- affectDescendants(node, !checked);
- // 再处理祖先节点
- affectAncestors(node);
- }
- this.setCheckedKeys(Array.from(current));
- },
- getSiblings(node) {
- const nodeKey = this.keyOf(node);
- const res = this.findParentAndSiblings(this.data, null, nodeKey);
- return res && res.siblings ? res.siblings : [];
- },
- findParentAndSiblings(nodes, parent, targetKey) {
- if (!nodes) return null;
- for (const n of nodes) {
- const nodeKey = this.keyOf(n);
- if (nodeKey === targetKey) {
- return {
- parent,
- siblings: parent
- ? (parent[this.childrenField] || []).filter(
- (sibling) => this.keyOf(sibling) !== targetKey,
- )
- : [],
- };
- }
- const found = this.findParentAndSiblings(
- n[this.childrenField],
- n,
- targetKey,
- );
- if (found) return found;
- }
- return null;
- },
- findParent(nodes, targetKey, parent = null) {
- if (!nodes) return null;
- for (const n of nodes) {
- const nodeKey = this.keyOf(n);
- if (nodeKey === targetKey) {
- return parent;
- }
- const found = this.findParent(
- n[this.childrenField],
- targetKey,
- n,
- );
- if (found) return found;
- }
- return null;
- },
- hasDescendantMatch(node) {
- if (!this.pattern) return false;
- const stack = [...(this.childrenOf(node) || [])];
- while (stack.length) {
- const n = stack.pop();
- if (!n) continue;
- const label = (n[this.labelField] + '').toLowerCase();
- if (label.includes(this.pattern.toLowerCase())) return true;
- stack.push(...(this.childrenOf(n) || []));
- }
- return false;
- },
- setLoading(node, value) {
- const key = this.keyOf(node);
- if (!this.loadingMap) this.loadingMap = {};
- if (this.$set) {
- this.$set(this.loadingMap, key, value);
- } else {
- this.loadingMap = { ...this.loadingMap, [key]: value };
- }
- },
- isLoading(node) {
- const key = this.keyOf(node);
- return !!(this.loadingMap && this.loadingMap[key]);
- },
- setExpandedKeys(next) {
- if (this.expandedKeys) {
- this.$emit('update:expanded-keys', next);
- this.$emit('expanded', next);
- } else {
- this.innerExpandedKeys = next;
- this.$emit('expanded', next);
- }
- },
- setCheckedKeys(next) {
- if (this.checkedKeys) {
- this.$emit('update:checked-keys', next);
- this.$emit('checked', next);
- } else {
- this.innerCheckedKeys = next;
- this.$emit('checked', next);
- }
- },
- },
- };
- </script>
- <style lang="scss" scoped>
- @import '../../libs/css/components.scss';
- .u-tree {
-
- &__nodes {
- @include flex(column);
- }
- &__node-wrapper {
- @include flex(row);
- align-items: center;
- }
- &__switcher {
- margin-right: 6px;
- }
- &__content {
- flex: 1;
- border-radius: 4px;
- padding: 2px 0px;
- }
- &__label {
- //#ifndef APP-NVUE
- display: inline-block;
- //#endif
-
- font-size: 14px;
- border-radius: 4px;
- padding: 2px 0px;
- }
- }
- </style>
|