一单四制优化及加入工作流
This commit is contained in:
@@ -1,27 +1,35 @@
|
||||
<template>
|
||||
<view class="u-tree-node" :style="{ paddingLeft: depth * 20 + 'px' }">
|
||||
<view class="u-tree-node-content" @click="toggle">
|
||||
<!-- <text v-if="hasChildren" class="u-tree-node-toggle">
|
||||
{{ node.expanded ? '▼' : '▶' }}
|
||||
</text> -->
|
||||
<up-icon v-if="hasChildren" class="u-tree-node-toggle"
|
||||
:name="node.expanded ? 'arrow-down-fill' : 'play-right-fill'" size="12" />
|
||||
<view class="u-tree-node">
|
||||
<view class="u-tree-node-content" :style="{ paddingLeft: depth * indent + 'rpx' }" @click="toggle">
|
||||
<up-icon
|
||||
v-if="hasChildren"
|
||||
class="u-tree-node-toggle"
|
||||
:name="isExpanded ? collapseIcon : expandIcon"
|
||||
:size="iconSize"
|
||||
/>
|
||||
<view v-else class="u-tree-node-toggle u-tree-node-toggle--placeholder"></view>
|
||||
<up-checkbox
|
||||
v-if="showCheckbox"
|
||||
usedAlone
|
||||
:size="12"
|
||||
:size="checkboxSize"
|
||||
:checked="node.checked"
|
||||
@change="toggleCheck"
|
||||
style="margin-right: 10px;"
|
||||
/>
|
||||
<slot :nodeData="node" :level="depth + 1">
|
||||
{{ node[props.label] }}
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<text class="u-tree-node-label" :class="labelClass">{{ node[props.label] }}</text>
|
||||
<!-- #endif -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<slot :node="node" :data="node" :level="depth + 1" :expanded="isExpanded" :checked="!!node.checked" :disabled="isDisabled">
|
||||
<text class="u-tree-node-label" :class="labelClass">{{ node[props.label] }}</text>
|
||||
</slot>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
<view v-if="hasChildren && (node.expanded === undefined ? true : node.expanded)"
|
||||
<view
|
||||
v-if="hasChildren && isExpanded"
|
||||
class="u-tree-node-children"
|
||||
:style="{ paddingLeft: (depth + 1) * 20 + 'px' }">
|
||||
<tree-node
|
||||
>
|
||||
<TreeNode
|
||||
v-for="child in node[props.children]"
|
||||
:key="child[props.nodeKey]"
|
||||
:node="child"
|
||||
@@ -29,20 +37,31 @@
|
||||
:show-checkbox="showCheckbox"
|
||||
:check-strictly="checkStrictly"
|
||||
:expand-on-click-node="expandOnClickNode"
|
||||
:highlight-current="highlightCurrent"
|
||||
:current-node-key="currentNodeKey"
|
||||
:indent="indent"
|
||||
:icon-size="iconSize"
|
||||
:checkbox-size="checkboxSize"
|
||||
:expand-icon="expandIcon"
|
||||
:collapse-icon="collapseIcon"
|
||||
:depth="depth + 1"
|
||||
@node-click="$emit('node-click', $event)"
|
||||
@check-change="$emit('check-change', $event)">
|
||||
<template #default="{ nodeData, level }">
|
||||
<slot name="default" :nodeData="nodeData" :level="level"></slot>
|
||||
@check-change="$emit('check-change', $event)"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot name="default" v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</tree-node>
|
||||
</TreeNode>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TreeNode from './tree-node.vue';
|
||||
|
||||
export default {
|
||||
name: 'tree-node',
|
||||
components: { TreeNode },
|
||||
props: {
|
||||
node: {
|
||||
type: Object,
|
||||
@@ -64,9 +83,37 @@ export default {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
highlightCurrent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentNodeKey: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
indent: {
|
||||
type: [String, Number],
|
||||
default: 32
|
||||
},
|
||||
iconSize: {
|
||||
type: [String, Number],
|
||||
default: 14
|
||||
},
|
||||
checkboxSize: {
|
||||
type: [String, Number],
|
||||
default: 16
|
||||
},
|
||||
expandIcon: {
|
||||
type: String,
|
||||
default: 'play-right-fill'
|
||||
},
|
||||
collapseIcon: {
|
||||
type: String,
|
||||
default: 'arrow-down-fill'
|
||||
},
|
||||
depth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -74,12 +121,29 @@ export default {
|
||||
return this.node[this.props.children] && this.node[this.props.children].length > 0;
|
||||
},
|
||||
isExpanded() {
|
||||
return this.node.expanded === undefined ? false : this.node.expanded;
|
||||
return this.node.expanded === undefined ? false : this.node.expanded;
|
||||
},
|
||||
isDisabled() {
|
||||
const disabledKey = this.props.disabled || 'disabled';
|
||||
return !!this.node[disabledKey];
|
||||
},
|
||||
isCurrent() {
|
||||
if (!this.highlightCurrent || this.currentNodeKey === '' || this.currentNodeKey === null || this.currentNodeKey === undefined) {
|
||||
return false;
|
||||
}
|
||||
return String(this.node[this.props.nodeKey]) === String(this.currentNodeKey);
|
||||
},
|
||||
labelClass() {
|
||||
return {
|
||||
'u-tree-node-label--current': this.isCurrent,
|
||||
'u-tree-node-label--disabled': this.isDisabled
|
||||
};
|
||||
}
|
||||
},
|
||||
emits: ['node-click', 'check-change'],
|
||||
methods: {
|
||||
toggle() {
|
||||
if (this.isDisabled) return;
|
||||
if (this.expandOnClickNode && this.hasChildren) {
|
||||
this.node.expanded = !this.node.expanded;
|
||||
}
|
||||
@@ -91,11 +155,11 @@ export default {
|
||||
this.updateChildCheckStatus(this.node, checked);
|
||||
this.updateParentCheckStatus(this.node);
|
||||
}
|
||||
this.$emit('check-change', this.node);
|
||||
this.$emit('check-change', this.node, checked);
|
||||
},
|
||||
updateChildCheckStatus(node, checked) {
|
||||
if (node[this.props.children]) {
|
||||
node[this.props.children].forEach(child => {
|
||||
node[this.props.children].forEach((child) => {
|
||||
child.checked = checked;
|
||||
this.updateChildCheckStatus(child, checked);
|
||||
});
|
||||
@@ -104,9 +168,7 @@ export default {
|
||||
updateParentCheckStatus(node) {
|
||||
let parent = this.$parent;
|
||||
while (parent && parent.node) {
|
||||
const allChecked = parent.node[this.props.children].every(
|
||||
child => child.checked
|
||||
);
|
||||
const allChecked = parent.node[this.props.children].every((child) => child.checked);
|
||||
parent.node.checked = allChecked;
|
||||
parent = parent.$parent;
|
||||
}
|
||||
@@ -120,9 +182,31 @@ export default {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding-left: 20px;
|
||||
min-height: 72rpx;
|
||||
padding-right: 20rpx;
|
||||
}
|
||||
|
||||
.u-tree-node-toggle {
|
||||
margin-right: 5px;
|
||||
margin-right: 8rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
.u-tree-node-toggle--placeholder {
|
||||
width: 14px;
|
||||
}
|
||||
|
||||
.u-tree-node-label {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.u-tree-node-label--current {
|
||||
color: #2667E9;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.u-tree-node-label--disabled {
|
||||
color: #c0c4cc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
<template>
|
||||
<view class="u-tree">
|
||||
<tree-node
|
||||
<TreeNode
|
||||
v-for="node in treeData"
|
||||
:key="node[props.nodeKey]"
|
||||
:key="node[nodeKeyName]"
|
||||
:node="node"
|
||||
:props="props"
|
||||
:show-checkbox="showCheckbox"
|
||||
:check-strictly="checkStrictly"
|
||||
:expand-on-click-node="expandOnClickNode"
|
||||
:highlight-current="highlightCurrent"
|
||||
:current-node-key="innerCurrentNodeKey"
|
||||
:indent="indent"
|
||||
:icon-size="iconSize"
|
||||
:checkbox-size="checkboxSize"
|
||||
:expand-icon="expandIcon"
|
||||
:collapse-icon="collapseIcon"
|
||||
@node-click="handleNodeClick"
|
||||
@check-change="$emit('check-change', $event)">
|
||||
<template #default="{ nodeData, level }">
|
||||
<slot :node="nodeData" :level="level"></slot>
|
||||
@check-change="handleCheckChange"
|
||||
>
|
||||
<template #default="slotProps">
|
||||
<slot v-bind="slotProps"></slot>
|
||||
</template>
|
||||
</tree-node>
|
||||
</TreeNode>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -26,16 +34,21 @@ export default {
|
||||
props: {
|
||||
data: {
|
||||
type: Array,
|
||||
required: true
|
||||
default: () => []
|
||||
},
|
||||
props: {
|
||||
type: Object,
|
||||
default: () => ({
|
||||
label: 'label',
|
||||
children: 'children',
|
||||
nodeKey: 'id'
|
||||
nodeKey: 'id',
|
||||
disabled: 'disabled'
|
||||
})
|
||||
},
|
||||
nodeKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
showCheckbox: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
@@ -44,67 +57,167 @@ export default {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
defaultExpandedKeys: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
defaultCheckedKeys: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
expandOnClickNode: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
checkOnClickNode: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
checkStrictly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
accordion: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
highlightCurrent: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
currentNodeKey: {
|
||||
type: [String, Number],
|
||||
default: ''
|
||||
},
|
||||
indent: {
|
||||
type: [String, Number],
|
||||
default: 32
|
||||
},
|
||||
iconSize: {
|
||||
type: [String, Number],
|
||||
default: 14
|
||||
},
|
||||
checkboxSize: {
|
||||
type: [String, Number],
|
||||
default: 16
|
||||
},
|
||||
expandIcon: {
|
||||
type: String,
|
||||
default: 'play-right-fill'
|
||||
},
|
||||
collapseIcon: {
|
||||
type: String,
|
||||
default: 'arrow-down-fill'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
treeData: []
|
||||
treeData: [],
|
||||
innerCurrentNodeKey: ''
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.initTree();
|
||||
computed: {
|
||||
nodeKeyName() {
|
||||
return this.nodeKey || this.props.nodeKey || 'id';
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
data: {
|
||||
handler(newVal) {
|
||||
this.treeData = JSON.parse(JSON.stringify(newVal));
|
||||
this.initExpandedState(this.treeData, this.defaultExpandAll);
|
||||
this.initTree(newVal);
|
||||
},
|
||||
deep: true,
|
||||
immediate: true
|
||||
},
|
||||
currentNodeKey: {
|
||||
handler(val) {
|
||||
this.innerCurrentNodeKey = val;
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
emits: ['node-click', 'check-change'],
|
||||
emits: ['node-click', 'check-change', 'check', 'node-expand', 'node-collapse', 'current-change'],
|
||||
methods: {
|
||||
initTree() {
|
||||
this.treeData = JSON.parse(JSON.stringify(this.data));
|
||||
this.initExpandedState(this.treeData, this.defaultExpandAll);
|
||||
initTree(data = this.data) {
|
||||
this.treeData = JSON.parse(JSON.stringify(data || []));
|
||||
this.applyDefaultState(this.treeData);
|
||||
},
|
||||
initExpandedState(nodes, expanded) {
|
||||
nodes.forEach(node => {
|
||||
node.expanded = expanded;
|
||||
if (node[this.props.children]) {
|
||||
this.initExpandedState(node[this.props.children], expanded);
|
||||
applyDefaultState(nodes, parent = null) {
|
||||
nodes.forEach((node) => {
|
||||
const key = node[this.nodeKeyName];
|
||||
if (this.defaultExpandAll) {
|
||||
node.expanded = true;
|
||||
} else if (this.defaultExpandedKeys.length) {
|
||||
node.expanded = this.defaultExpandedKeys.map(String).includes(String(key));
|
||||
} else {
|
||||
node.expanded = false;
|
||||
}
|
||||
if (this.defaultCheckedKeys.length) {
|
||||
node.checked = this.defaultCheckedKeys.map(String).includes(String(key));
|
||||
}
|
||||
const children = node[this.props.children];
|
||||
if (children && children.length) {
|
||||
this.applyDefaultState(children, node);
|
||||
}
|
||||
});
|
||||
},
|
||||
handleNodeClick(node) {
|
||||
if (this.highlightCurrent) {
|
||||
const oldKey = this.innerCurrentNodeKey;
|
||||
this.innerCurrentNodeKey = node[this.nodeKeyName];
|
||||
this.$emit('current-change', node, oldKey);
|
||||
}
|
||||
if (this.checkOnClickNode && this.showCheckbox) {
|
||||
node.checked = !node.checked;
|
||||
this.handleCheckChange(node, node.checked);
|
||||
}
|
||||
this.$emit('node-click', node);
|
||||
},
|
||||
/**
|
||||
* 直接递归 treeData 获取所有 checked 的节点
|
||||
*/
|
||||
getCheckedNodes() {
|
||||
const traverse = (nodes) => {
|
||||
let result = [];
|
||||
nodes.forEach(node => {
|
||||
if (node.checked) {
|
||||
result.push(node);
|
||||
}
|
||||
if (node[this.props.children] && node[this.props.children].length > 0) {
|
||||
result = result.concat(traverse(node[this.props.children]));
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
return traverse(this.treeData);
|
||||
handleCheckChange(node, checked) {
|
||||
this.$emit('check-change', node, checked);
|
||||
this.$emit('check', node, {
|
||||
checkedNodes: this.getCheckedNodes(),
|
||||
checkedKeys: this.getCheckedKeys(),
|
||||
halfCheckedNodes: [],
|
||||
halfCheckedKeys: []
|
||||
});
|
||||
},
|
||||
traverse(nodes, callback) {
|
||||
nodes.forEach((node) => {
|
||||
callback(node);
|
||||
const children = node[this.props.children];
|
||||
if (children && children.length) {
|
||||
this.traverse(children, callback);
|
||||
}
|
||||
});
|
||||
},
|
||||
getCheckedNodes(leafOnly = false) {
|
||||
const result = [];
|
||||
this.traverse(this.treeData, (node) => {
|
||||
if (!node.checked) return;
|
||||
const children = node[this.props.children] || [];
|
||||
if (leafOnly && children.length) return;
|
||||
result.push(node);
|
||||
});
|
||||
return result;
|
||||
},
|
||||
getCheckedKeys(leafOnly = false) {
|
||||
return this.getCheckedNodes(leafOnly).map((node) => node[this.nodeKeyName]);
|
||||
},
|
||||
setCurrentKey(key) {
|
||||
this.innerCurrentNodeKey = key;
|
||||
},
|
||||
getCurrentKey() {
|
||||
return this.innerCurrentNodeKey;
|
||||
},
|
||||
getCurrentNode() {
|
||||
let current = null;
|
||||
this.traverse(this.treeData, (node) => {
|
||||
if (String(node[this.nodeKeyName]) === String(this.innerCurrentNodeKey)) {
|
||||
current = node;
|
||||
}
|
||||
});
|
||||
return current;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -114,4 +227,4 @@ export default {
|
||||
.u-tree {
|
||||
font-size: 28rpx;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
21
uni_modules/xq-tree/changelog.md
Normal file
21
uni_modules/xq-tree/changelog.md
Normal file
@@ -0,0 +1,21 @@
|
||||
## 1.0.2(2026-04-28)
|
||||
插槽去掉data字段
|
||||
## 1.0.1(2026-04-26)
|
||||
预览图片
|
||||
## 1.0.0(2026-04-26)
|
||||
## [1.0.0] - 2026-04-26
|
||||
|
||||
### 🚀 首次发布
|
||||
|
||||
- ✅ **树形展示**:基础树形列表渲染,支持无限层级数据展示
|
||||
- ✅ **展开/折叠**:节点可展开折叠,支持深层递归展开所有节点
|
||||
- ✅ **手风琴模式**:同级节点只保留一个展开,保持界面整洁
|
||||
- ✅ **多选复选框**:显示复选框,支持严格模式(父子独立)和非严格模式(父子联动),父节点显示半选状态
|
||||
- ✅ **单选模式**:`multiple` 属性控制,关闭后仅允许选中一个节点
|
||||
- ✅ **交错动画**:展开/折叠时子节点有平滑的下落和收起动画,视觉流畅
|
||||
- ✅ **自定义节点内容**:通过 `#node` 插槽自定义节点内容,图标也可通过 `#icon` 插槽替换
|
||||
- ✅ **固定宽高滚动**:通过 `height`、`width` 属性限制容器,内容溢出时自动出现滚动条
|
||||
- ✅ **丰富的方法暴露**:提供全选、清空选中、指定节点选中、获取选中节点等方法
|
||||
- ✅ **灵活字段映射**:支持自定义 `nodeKey`、`labelKey`、`childrenKey` 等字段名
|
||||
- ✅ **微信小程序兼容**:小程序端使用纯文本箭头图标,性能优化,插槽可选择性开启
|
||||
- ✅ **依赖声明**:插件根目录 `package.json` 中声明依赖 `@dcloudio/uni-ui`,导入时自动安装
|
||||
848
uni_modules/xq-tree/components/xq-tree/xq-tree.vue
Normal file
848
uni_modules/xq-tree/components/xq-tree/xq-tree.vue
Normal file
@@ -0,0 +1,848 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, provide, reactive, ref, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
data: { type: Array, default: () => [] },
|
||||
nodeKey: { type: String, default: 'id' },
|
||||
labelKey: { type: String, default: 'label' },
|
||||
childrenKey: { type: String, default: 'children' },
|
||||
indent: { type: Number, default: 20 },
|
||||
showCheckbox: { type: Boolean, default: false },
|
||||
defaultExpandAll: { type: Boolean, default: false },
|
||||
defaultExpandedKeys: { type: Array, default: () => [] },
|
||||
defaultCheckedKeys: { type: Array, default: () => [] },
|
||||
checkStrictly: { type: Boolean, default: true }, // 是否严格模式(父子互不关联)
|
||||
emptyText: { type: String, default: '暂无数据' },
|
||||
checkedKey: { // 节点的 checked 状态字段名
|
||||
type: String,
|
||||
default: '_checked',
|
||||
},
|
||||
accordion: { // 手风琴
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
multiple: { // 多选
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isDeepExpand: { // 方法是否深层展开
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
height: { type: Number, default: 500 },
|
||||
width: { type: Number, default: 300 },
|
||||
labelSlot: { // 微信小程序不适用插槽的时候就不要打开了,真的无语了
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['node-click', 'node-expand', 'node-collapse', 'check-change', 'check'])
|
||||
|
||||
// 辅助函数
|
||||
const getNodeKey = node => node[props.nodeKey]
|
||||
function hasChildren(node) {
|
||||
const children = node[props.childrenKey]
|
||||
return children && Array.isArray(children) && children.length > 0
|
||||
}
|
||||
|
||||
// 展开状态 (reactive 对象,避免克隆原始数据)
|
||||
const expandedState = reactive({})
|
||||
|
||||
// 初始化展开状态
|
||||
function initExpanded() {
|
||||
Object.keys(expandedState).forEach(k => delete expandedState[k])
|
||||
if (props.defaultExpandAll) {
|
||||
const setAll = (list) => {
|
||||
list.forEach((item) => {
|
||||
expandedState[getNodeKey(item)] = true
|
||||
const children = item[props.childrenKey]
|
||||
if (children)
|
||||
setAll(children)
|
||||
})
|
||||
}
|
||||
setAll(props.data)
|
||||
}
|
||||
else if (props.defaultExpandedKeys.length) {
|
||||
props.defaultExpandedKeys.forEach((key) => { expandedState[key] = true })
|
||||
}
|
||||
}
|
||||
initExpanded()
|
||||
watch(() => props.data, initExpanded, { deep: false })
|
||||
|
||||
// 扁平的节点列表 (递归遍历,根据展开状态决定是否包含子节点)
|
||||
const flatData = computed(() => {
|
||||
const result = []
|
||||
const flatten = (list, level = 0, parent = null) => {
|
||||
if (!list?.length)
|
||||
return
|
||||
list.forEach((node, idx) => {
|
||||
const key = getNodeKey(node)
|
||||
const expanded = !!expandedState[key]
|
||||
const _node = reactive({
|
||||
...node,
|
||||
_level: level,
|
||||
_expanded: expanded,
|
||||
[props.checkedKey]: false,
|
||||
_indeterminate: false,
|
||||
_parent: parent,
|
||||
_siblingIndex: idx, // 关键:同级索引
|
||||
raw: node,
|
||||
})
|
||||
result.push(_node)
|
||||
if (expanded && hasChildren(node)) {
|
||||
flatten(node[props.childrenKey], level + 1, _node)
|
||||
}
|
||||
})
|
||||
}
|
||||
flatten(props.data)
|
||||
return result
|
||||
})
|
||||
|
||||
// 多选逻辑
|
||||
const checkedKeys = reactive(new Set())
|
||||
// 初始化默认勾选
|
||||
watch(() => props.defaultCheckedKeys, (keys) => {
|
||||
checkedKeys.clear()
|
||||
keys.forEach(k => checkedKeys.add(k))
|
||||
}, { immediate: true })
|
||||
|
||||
/**
|
||||
* 递归计算并设置节点及其子树的 checked/indeterminate 状态
|
||||
* @param {object} node 原始数据节点
|
||||
* @param {object} flatNode 扁平化节点映射(key -> reactive node),用于写入状态
|
||||
*/
|
||||
function syncNodeState(node, flatNodeMap) {
|
||||
if (!node)
|
||||
return { checked: false, indeterminate: false }
|
||||
|
||||
const nodeKey = getNodeKey(node)
|
||||
const children = node[props.childrenKey] || []
|
||||
|
||||
if (props.checkStrictly) {
|
||||
// 严格模式:状态完全由 checkedKeys 决定,不计算父子关系
|
||||
const isChecked = checkedKeys.has(nodeKey)
|
||||
if (flatNodeMap.has(nodeKey)) {
|
||||
const flatNode = flatNodeMap.get(nodeKey)
|
||||
flatNode[props.checkedKey] = isChecked
|
||||
flatNode._indeterminate = false
|
||||
}
|
||||
// 递归子节点以刷新它们的视图(但不会反向修改父节点)
|
||||
children.forEach(child => syncNodeState(child, flatNodeMap))
|
||||
return { checked: isChecked, indeterminate: false }
|
||||
}
|
||||
|
||||
// 非严格模式:需要父子联动
|
||||
let allChildrenChecked = true
|
||||
let hasCheckedChild = false
|
||||
let hasIndeterminateChild = false
|
||||
|
||||
for (const child of children) {
|
||||
const childResult = syncNodeState(child, flatNodeMap)
|
||||
if (!childResult.checked)
|
||||
allChildrenChecked = false
|
||||
if (childResult.checked)
|
||||
hasCheckedChild = true
|
||||
if (childResult.indeterminate)
|
||||
hasIndeterminateChild = true
|
||||
}
|
||||
|
||||
let finalChecked, finalIndeterminate
|
||||
|
||||
if (children.length === 0) {
|
||||
// 叶子节点:状态完全由 checkedKeys 决定
|
||||
finalChecked = checkedKeys.has(nodeKey)
|
||||
finalIndeterminate = false
|
||||
}
|
||||
else {
|
||||
// 父节点:状态仅取决于子节点,自身不自动被勾选
|
||||
if (allChildrenChecked && children.length > 0) {
|
||||
// 所有子节点全选 → 父节点为半选(不自动加入 checkedKeys)
|
||||
checkedKeys.delete(nodeKey)
|
||||
finalChecked = false
|
||||
finalIndeterminate = true
|
||||
}
|
||||
else if (hasCheckedChild || hasIndeterminateChild) {
|
||||
// 部分子节点选中或存在半选 → 父节点为半选
|
||||
checkedKeys.delete(nodeKey)
|
||||
finalChecked = false
|
||||
finalIndeterminate = true
|
||||
}
|
||||
else {
|
||||
// 没有子节点被选中 → 父节点未选中
|
||||
checkedKeys.delete(nodeKey)
|
||||
finalChecked = false
|
||||
finalIndeterminate = false
|
||||
}
|
||||
}
|
||||
|
||||
// 将状态写入当前可见的扁平节点(如果该节点已展开)
|
||||
if (flatNodeMap.has(nodeKey)) {
|
||||
const flatNode = flatNodeMap.get(nodeKey)
|
||||
flatNode[props.checkedKey] = finalChecked
|
||||
flatNode._indeterminate = finalIndeterminate
|
||||
}
|
||||
|
||||
return { checked: finalChecked, indeterminate: finalIndeterminate }
|
||||
}
|
||||
// function syncNodeState(node, flatNodeMap) {
|
||||
// if (!node)
|
||||
// return { checked: false, indeterminate: false }
|
||||
|
||||
// const nodeKey = getNodeKey(node)
|
||||
// const children = node[props.childrenKey] || []
|
||||
|
||||
// if (props.checkStrictly) {
|
||||
// // 严格模式:状态完全由 checkedKeys 决定,不计算父子关系
|
||||
// const isChecked = checkedKeys.has(nodeKey)
|
||||
// if (flatNodeMap.has(nodeKey)) {
|
||||
// const flatNode = flatNodeMap.get(nodeKey)
|
||||
// flatNode[props.checkedKey] = isChecked
|
||||
// flatNode._indeterminate = false // 严格模式没有半选
|
||||
// }
|
||||
// // 仍需递归子节点以刷新它们的视图(但不会反向修改父节点)
|
||||
// children.forEach(child => syncNodeState(child, flatNodeMap))
|
||||
// return { checked: isChecked, indeterminate: false }
|
||||
// }
|
||||
|
||||
// // 非严格模式:需要父子联动
|
||||
// let allChildrenChecked = true
|
||||
// let hasCheckedChild = false
|
||||
// let hasIndeterminateChild = false
|
||||
|
||||
// for (const child of children) {
|
||||
// const childResult = syncNodeState(child, flatNodeMap)
|
||||
// if (!childResult.checked)
|
||||
// allChildrenChecked = false
|
||||
// if (childResult.checked)
|
||||
// hasCheckedChild = true
|
||||
// if (childResult.indeterminate)
|
||||
// hasIndeterminateChild = true
|
||||
// }
|
||||
|
||||
// let finalChecked, finalIndeterminate
|
||||
|
||||
// if (children.length === 0) {
|
||||
// finalChecked = checkedKeys.has(nodeKey)
|
||||
// finalIndeterminate = false
|
||||
// }
|
||||
// else {
|
||||
// if (allChildrenChecked && children.length > 0) {
|
||||
// checkedKeys.add(nodeKey)
|
||||
// finalChecked = true
|
||||
// finalIndeterminate = false
|
||||
// }
|
||||
// else if (hasCheckedChild || hasIndeterminateChild) {
|
||||
// checkedKeys.delete(nodeKey)
|
||||
// finalChecked = false
|
||||
// finalIndeterminate = true
|
||||
// }
|
||||
// else {
|
||||
// checkedKeys.delete(nodeKey)
|
||||
// finalChecked = false
|
||||
// finalIndeterminate = false
|
||||
// }
|
||||
// }
|
||||
|
||||
// if (flatNodeMap.has(nodeKey)) {
|
||||
// const flatNode = flatNodeMap.get(nodeKey)
|
||||
// flatNode[props.checkedKey] = finalChecked
|
||||
// flatNode._indeterminate = finalIndeterminate
|
||||
// }
|
||||
|
||||
// return { checked: finalChecked, indeterminate: finalIndeterminate }
|
||||
// }
|
||||
// 递归更新节点勾选状态 (由外部调用或响应勾选变化)
|
||||
function updateCheckedState() {
|
||||
if (props.checkStrictly) {
|
||||
// 严格模式:直接同步,无需父子计算
|
||||
flatData.value.forEach((node) => {
|
||||
const key = getNodeKey(node)
|
||||
node[props.checkedKey] = checkedKeys.has(key)
|
||||
node._indeterminate = false
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 非严格模式:需要根据子节点状态计算父节点的 checked 和 indeterminate
|
||||
// 构建 key -> reactive node 映射
|
||||
const flatNodeMap = new Map()
|
||||
flatData.value.forEach(n => flatNodeMap.set(getNodeKey(n), n))
|
||||
|
||||
// 递归同步整棵树的状态(处理父子联动)
|
||||
const syncRecursive = (node) => {
|
||||
if (!node)
|
||||
return { checked: false, indeterminate: false }
|
||||
|
||||
const children = node[props.childrenKey] || []
|
||||
let allChildrenChecked = true
|
||||
let hasCheckedChild = false
|
||||
let hasIndeterminateChild = false
|
||||
|
||||
for (const child of children) {
|
||||
const childResult = syncRecursive(child)
|
||||
if (!childResult.checked)
|
||||
allChildrenChecked = false
|
||||
if (childResult.checked)
|
||||
hasCheckedChild = true
|
||||
if (childResult.indeterminate)
|
||||
hasIndeterminateChild = true
|
||||
}
|
||||
|
||||
const nodeKey = getNodeKey(node)
|
||||
let finalChecked, finalIndeterminate
|
||||
|
||||
if (children.length === 0) {
|
||||
finalChecked = checkedKeys.has(nodeKey)
|
||||
finalIndeterminate = false
|
||||
}
|
||||
else {
|
||||
if (allChildrenChecked && children.length > 0) {
|
||||
checkedKeys.add(nodeKey)
|
||||
finalChecked = true
|
||||
finalIndeterminate = false
|
||||
}
|
||||
else if (hasCheckedChild || hasIndeterminateChild) {
|
||||
checkedKeys.delete(nodeKey)
|
||||
finalChecked = false
|
||||
finalIndeterminate = true
|
||||
}
|
||||
else {
|
||||
checkedKeys.delete(nodeKey)
|
||||
finalChecked = false
|
||||
finalIndeterminate = false
|
||||
}
|
||||
}
|
||||
|
||||
if (flatNodeMap.has(nodeKey)) {
|
||||
const flatNode = flatNodeMap.get(nodeKey)
|
||||
flatNode[props.checkedKey] = finalChecked
|
||||
flatNode._indeterminate = finalIndeterminate
|
||||
}
|
||||
|
||||
return { checked: finalChecked, indeterminate: finalIndeterminate }
|
||||
}
|
||||
|
||||
props.data.forEach(root => syncRecursive(root))
|
||||
}
|
||||
|
||||
// 勾选处理
|
||||
function handleCheck(node) {
|
||||
const key = getNodeKey(node)
|
||||
|
||||
if (props.checkStrictly) {
|
||||
// 严格模式:只切换当前节点
|
||||
if (checkedKeys.has(key)) {
|
||||
checkedKeys.delete(key)
|
||||
}
|
||||
else {
|
||||
checkedKeys.add(key)
|
||||
}
|
||||
updateCheckedState()
|
||||
emit('check-change', node, checkedKeys.has(key))
|
||||
emit('check', node, { checkedKeys: [...checkedKeys] })
|
||||
return
|
||||
}
|
||||
|
||||
// 非严格模式:处理当前节点及子节点
|
||||
const shouldCheck = !checkedKeys.has(key)
|
||||
const toggleRecursive = (n) => {
|
||||
const nKey = getNodeKey(n)
|
||||
if (shouldCheck) {
|
||||
checkedKeys.add(nKey)
|
||||
}
|
||||
else {
|
||||
checkedKeys.delete(nKey)
|
||||
}
|
||||
const children = n[props.childrenKey] || []
|
||||
children.forEach(child => toggleRecursive(child))
|
||||
}
|
||||
toggleRecursive(node)
|
||||
|
||||
updateCheckedState()
|
||||
emit('check-change', node, shouldCheck)
|
||||
emit('check', node, { checkedKeys: [...checkedKeys] })
|
||||
}
|
||||
|
||||
// 展开/折叠
|
||||
function toggleExpand(node) {
|
||||
const key = getNodeKey(node)
|
||||
const wasExpanded = expandedState[key]
|
||||
|
||||
if (wasExpanded) {
|
||||
delete expandedState[key]
|
||||
emit('node-collapse', node)
|
||||
}
|
||||
else {
|
||||
if (props.accordion) {
|
||||
const siblings = node._parent
|
||||
? (node._parent[props.childrenKey] || [])
|
||||
: props.data
|
||||
siblings.forEach((sib) => {
|
||||
const sibKey = getNodeKey(sib)
|
||||
if (sibKey !== key && expandedState[sibKey]) {
|
||||
delete expandedState[sibKey]
|
||||
}
|
||||
})
|
||||
}
|
||||
expandedState[key] = true
|
||||
emit('node-expand', node)
|
||||
}
|
||||
|
||||
nextTick(() => updateCheckedState())
|
||||
}
|
||||
|
||||
// 节点点击
|
||||
function handleNodeClick(node) {
|
||||
emit('node-click', node)
|
||||
}
|
||||
|
||||
// 暴露方法
|
||||
const getCheckedKeys = () => [...checkedKeys]
|
||||
const getCheckedNodes = () => flatData.value.filter(n => n[props.checkedKey]).map(n => n.raw)
|
||||
function setCheckedKeys(keys) {
|
||||
checkedKeys.clear()
|
||||
keys.forEach(k => checkedKeys.add(k))
|
||||
updateCheckedState()
|
||||
}
|
||||
function expandAll() {
|
||||
if (!props.isDeepExpand) {
|
||||
// 展开浅层
|
||||
flatData.value.forEach((node) => {
|
||||
const key = getNodeKey(node)
|
||||
if (hasChildren(node))
|
||||
expandedState[key] = true
|
||||
})
|
||||
}
|
||||
else {
|
||||
// 递归展开所有
|
||||
const expandRecursively = (list) => {
|
||||
if (!list?.length)
|
||||
return
|
||||
list.forEach((item) => {
|
||||
if (hasChildren(item)) {
|
||||
expandedState[getNodeKey(item)] = true
|
||||
expandRecursively(item[props.childrenKey])
|
||||
}
|
||||
})
|
||||
}
|
||||
expandRecursively(props.data)
|
||||
}
|
||||
|
||||
nextTick(() => updateCheckedState())
|
||||
}
|
||||
function collapseAll() {
|
||||
Object.keys(expandedState).forEach(k => delete expandedState[k])
|
||||
// 折叠后同步选中状态
|
||||
nextTick(() => updateCheckedState())
|
||||
}
|
||||
|
||||
// 清空所有勾选
|
||||
function clearChecked() {
|
||||
checkedKeys.clear()
|
||||
updateCheckedState()
|
||||
}
|
||||
|
||||
// 解析 key/节点,返回 { key, node },保证 key 类型与节点真实 nodeKey 一致
|
||||
function resolveKeyAndNode(keyOrNode) {
|
||||
let key, node
|
||||
if (typeof keyOrNode === 'object' && keyOrNode !== null) {
|
||||
node = keyOrNode
|
||||
key = getNodeKey(node) // 直接取真实 key
|
||||
}
|
||||
else {
|
||||
// 传入的是原始 key(可能是数字或字符串)
|
||||
node = findNodeByKey(keyOrNode, props.data)
|
||||
if (node) {
|
||||
key = getNodeKey(node) // 使用节点的真实 key,保证类型一致
|
||||
}
|
||||
else {
|
||||
key = keyOrNode // 找不到节点则保持原样(后续会过滤掉)
|
||||
}
|
||||
}
|
||||
return { key, node }
|
||||
}
|
||||
|
||||
// 递归查找原始节点
|
||||
function findNodeByKey(key, list) {
|
||||
if (!list)
|
||||
return null
|
||||
for (const item of list) {
|
||||
const itemKey = getNodeKey(item)
|
||||
// 首先严格匹配
|
||||
if (itemKey === key)
|
||||
return item
|
||||
// 如果严格匹配失败,尝试类型转换后匹配
|
||||
if (typeof itemKey === 'number' && itemKey === Number(key))
|
||||
return item
|
||||
if (typeof itemKey === 'string' && itemKey === String(key))
|
||||
return item
|
||||
// 递归子节点
|
||||
const found = findNodeByKey(key, item[props.childrenKey])
|
||||
if (found)
|
||||
return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
// 设置指定节点的勾选状态
|
||||
function setNodeChecked(keyOrNode, checked) {
|
||||
const { key, node } = resolveKeyAndNode(keyOrNode)
|
||||
if (!key)
|
||||
return // 找不到节点或 key 无效
|
||||
|
||||
if (checked) {
|
||||
checkedKeys.add(key)
|
||||
}
|
||||
else {
|
||||
checkedKeys.delete(key)
|
||||
}
|
||||
|
||||
// 非严格模式且是父节点时,处理所有子节点
|
||||
if (!props.checkStrictly && node && hasChildren(node)) {
|
||||
const toggleChildren = (children) => {
|
||||
if (!children)
|
||||
return
|
||||
children.forEach((child) => {
|
||||
const childKey = getNodeKey(child) // 子节点的真实 key,类型一致
|
||||
if (checked) {
|
||||
checkedKeys.add(childKey)
|
||||
}
|
||||
else {
|
||||
checkedKeys.delete(childKey)
|
||||
}
|
||||
toggleChildren(child[props.childrenKey])
|
||||
})
|
||||
}
|
||||
toggleChildren(node[props.childrenKey])
|
||||
}
|
||||
|
||||
updateCheckedState()
|
||||
emit('check-change', node || key, checked)
|
||||
emit('check', node || key, { checkedKeys: [...checkedKeys] })
|
||||
}
|
||||
|
||||
// 全选(选中所有叶子节点)
|
||||
function checkAll() {
|
||||
const selectAllNodes = (list) => {
|
||||
if (!list?.length)
|
||||
return
|
||||
list.forEach((item) => {
|
||||
// 将当前节点加入选中(严格模式下每个节点独立)
|
||||
checkedKeys.add(getNodeKey(item))
|
||||
// 递归处理子节点
|
||||
if (hasChildren(item)) {
|
||||
selectAllNodes(item[props.childrenKey])
|
||||
}
|
||||
})
|
||||
}
|
||||
selectAllNodes(props.data)
|
||||
updateCheckedState()
|
||||
emit('check', null, { checkedKeys: [...checkedKeys] })
|
||||
}
|
||||
|
||||
defineExpose({ getCheckedKeys, getCheckedNodes, setCheckedKeys, expandAll, collapseAll, clearChecked, setNodeChecked, checkAll })
|
||||
|
||||
// 初始化勾选状态
|
||||
updateCheckedState()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<scroll-view scroll-x scroll-y enable-flex class="tree-wrapper">
|
||||
<view class="xq-tree-scroll">
|
||||
<view
|
||||
class="xq-tree" :style="{
|
||||
minWidth: `${width}rpx`,
|
||||
minHeight: `${height}rpx`,
|
||||
}"
|
||||
>
|
||||
<!-- 空数据提示 -->
|
||||
<view v-if="flatData.length === 0" class="xq-tree-empty">
|
||||
{{ emptyText }}
|
||||
</view>
|
||||
<TransitionGroup v-else name="tree-node" tag="view" class="xq-tree-list">
|
||||
<view
|
||||
v-for="(node, index) in flatData" :key="node[nodeKey] || index" class="xq-tree-node" :class="[
|
||||
{ 'is-expanded': node._expanded, 'is-leaf': !hasChildren(node) },
|
||||
]" :style="{
|
||||
paddingLeft: `${(node._level || 0) * indent}px`,
|
||||
animationDelay: `${(node._siblingIndex || 0) * 0.03}s`,
|
||||
}"
|
||||
>
|
||||
<view class="xq-tree-node-content" @click="handleNodeClick(node)">
|
||||
<!-- 展开/折叠图标 -->
|
||||
<view class="xq-tree-node-icon" @click.stop="toggleExpand(node)">
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<text v-if="hasChildren(node)" class="uni-icon-warp">
|
||||
<slot name="icon" :node="node">
|
||||
<!-- <uni-icons type="right" size="18" class="icon-arrow" :class="{ 'is-expanded': node._expanded }" /> -->
|
||||
<text class="icon-arrow" :class="{ 'is-expanded': node._expanded }">
|
||||
▶
|
||||
</text>
|
||||
</slot>
|
||||
</text>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<text v-if="hasChildren(node)" class="icon-arrow" :class="{ 'is-expanded': node._expanded }">
|
||||
▶
|
||||
</text>
|
||||
<!-- #endif -->
|
||||
<view v-else class="leaf-icon-placeholder"></view>
|
||||
</view>
|
||||
|
||||
<!-- 复选框(如果开启多选) -->
|
||||
<view v-if="showCheckbox" class="xq-tree-node-checkbox" @click.stop="handleCheck(node)">
|
||||
<view
|
||||
class="checkbox-custom"
|
||||
:class="[{ checked: node[checkedKey], indeterminate: node._indeterminate }]"
|
||||
>
|
||||
<text v-if="node[checkedKey]">
|
||||
✓
|
||||
</text>
|
||||
<text v-else-if="node._indeterminate">
|
||||
—
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 节点内容插槽 -->
|
||||
<!-- #ifndef MP-WEIXIN -->
|
||||
<slot
|
||||
name="node" :node="node" :level="node._level" :expanded="node._expanded"
|
||||
:checked="node[checkedKey]" :indeterminate="node._indeterminate" :is-leaf="!hasChildren(node)"
|
||||
>
|
||||
<text class="xq-tree-node-label">
|
||||
{{ node[labelKey] }}
|
||||
</text>
|
||||
</slot>
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<!-- 微信的插槽真的浪费性能 -->
|
||||
<slot
|
||||
v-if="labelSlot" name="node" :node="node" :data="node" :level="node._level"
|
||||
:expanded="node._expanded" :checked="node[checkedKey]" :indeterminate="node._indeterminate"
|
||||
/>
|
||||
<text v-else class="xq-tree-node-label">
|
||||
{{ node[labelKey] }}
|
||||
</text>
|
||||
<!-- #endif -->
|
||||
</view>
|
||||
</view>
|
||||
</TransitionGroup>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.xq-tree {
|
||||
display: inline-block;
|
||||
/* ⚠️ 关键:宽度由内容撑开,不继承父容器宽度 */
|
||||
/* 内容不换行 */
|
||||
/* 至少撑满容器 */
|
||||
box-sizing: border-box;
|
||||
width: max-content;
|
||||
/* 宽度由内容决定,不换行 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// padding: 20rpx;
|
||||
/* 确保节点不换行 */
|
||||
}
|
||||
|
||||
.xq-tree-scroll {
|
||||
display: inline-block;
|
||||
/* ⚠️ 关键:宽度由内容撑开,不继承父容器宽度 */
|
||||
white-space: nowrap;
|
||||
/* 内容不换行 */
|
||||
min-width: 100%;
|
||||
/* 至少撑满容器 */
|
||||
box-sizing: border-box;
|
||||
background-color: #fff;
|
||||
padding: 16rpx;
|
||||
}
|
||||
|
||||
/* 小程序下 scroll-view 的滚动条样式由原生控制,通常无法自定义;H5 端同样可用 ::-webkit-scrollbar */
|
||||
/* 下面的样式仅对 H5 端生效 */
|
||||
.tree-wrapper::-webkit-scrollbar {
|
||||
height: 8rpx;
|
||||
width: 8rpx;
|
||||
}
|
||||
|
||||
.tree-wrapper::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
}
|
||||
|
||||
.tree-wrapper::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
// 进入动画(直接使用之前的 keyframes,但通过 TransitionGroup 自动触发)
|
||||
.tree-node-enter-active {
|
||||
animation: treeExpandIn 0.3s ease-out both;
|
||||
// animation-delay 已通过内联 style 设置,不需要在这里写死
|
||||
}
|
||||
|
||||
// 离开动画:使用 transition 让 max-height、padding、opacity 平滑过渡
|
||||
.tree-node-leave-active {
|
||||
animation: treeCollapseOut 0.3s ease-in both;
|
||||
overflow: hidden; // 确保高度变化时内容不会溢出
|
||||
}
|
||||
|
||||
.tree-node-leave-to {
|
||||
max-height: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
// 需要的关键帧 remain unchanged (可以保留)
|
||||
@keyframes treeExpandIn {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
padding-top: 8rpx;
|
||||
padding-bottom: 8rpx;
|
||||
}
|
||||
}
|
||||
|
||||
// 新增离开关键帧
|
||||
@keyframes treeCollapseOut {
|
||||
0% {
|
||||
max-height: 500px; // 开始时的实际高度,取一个足够大的值
|
||||
opacity: 1;
|
||||
padding-top: 8rpx;
|
||||
padding-bottom: 8rpx;
|
||||
}
|
||||
|
||||
100% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// 节点基本样式微调
|
||||
.xq-tree-node {
|
||||
padding: 8rpx 0;
|
||||
overflow: hidden;
|
||||
transform-origin: top;
|
||||
}
|
||||
|
||||
.xq-tree {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.xq-tree-empty {
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.xq-tree-node {
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.xq-tree-node-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.xq-tree-node-icon {
|
||||
width: 40rpx;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xq-tree-node-checkbox {
|
||||
margin-right: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.xq-tree-node-label {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.checkbox-custom {
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
border: 2rpx solid #ccc;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.checkbox-custom.checked {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.checkbox-custom.indeterminate {
|
||||
background-color: #007aff;
|
||||
border-color: #007aff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.leaf-icon-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.uni-icon-warp {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40rpx;
|
||||
height: 40rpx;
|
||||
}
|
||||
|
||||
.icon-arrow.is-expanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.icon-arrow {
|
||||
display: inline-block;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
// #ifdef H5
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// #endif
|
||||
// #ifdef MP-WEIXIN
|
||||
page {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
view,
|
||||
text,
|
||||
image,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
navigator,
|
||||
scroll-view {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// #endif
|
||||
</style>
|
||||
102
uni_modules/xq-tree/package.json
Normal file
102
uni_modules/xq-tree/package.json
Normal file
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"id": "xq-tree",
|
||||
"displayName": "xq-tree 可选择的树形组件",
|
||||
"version": "1.0.2",
|
||||
"description": "一款基于 uni-app 的高性能树形组件,支持手风琴模式、多选/单选、父子联动、严格模式、自定义插槽及流畅的展开折叠动画,完美兼容微信小程序、H5 和 App。",
|
||||
"keywords": [
|
||||
"xq-tree",
|
||||
"tree",
|
||||
"树形",
|
||||
"展开/折叠",
|
||||
"选中"
|
||||
],
|
||||
"repository": "",
|
||||
"engines": {
|
||||
"HBuilderX": "^3.1.0",
|
||||
"uni-app": "^4.0",
|
||||
"uni-app-x": "^4.44"
|
||||
},
|
||||
"dcloudext": {
|
||||
"type": "component-vue",
|
||||
"sale": {
|
||||
"regular": {
|
||||
"price": "0.00"
|
||||
},
|
||||
"sourcecode": {
|
||||
"price": "0.00"
|
||||
}
|
||||
},
|
||||
"contact": {
|
||||
"qq": ""
|
||||
},
|
||||
"declaration": {
|
||||
"ads": "无",
|
||||
"data": "无",
|
||||
"permissions": "无"
|
||||
},
|
||||
"npmurl": "",
|
||||
"darkmode": "x",
|
||||
"i18n": "x",
|
||||
"widescreen": "x"
|
||||
},
|
||||
"uni_modules": {
|
||||
"dependencies": [],
|
||||
"encrypt": [],
|
||||
"platforms": {
|
||||
"cloud": {
|
||||
"tcb": "x",
|
||||
"aliyun": "x",
|
||||
"alipay": "x"
|
||||
},
|
||||
"client": {
|
||||
"uni-app": {
|
||||
"vue": {
|
||||
"vue2": "-",
|
||||
"vue3": "√"
|
||||
},
|
||||
"web": {
|
||||
"safari": "-",
|
||||
"chrome": "√"
|
||||
},
|
||||
"app": {
|
||||
"vue": "√",
|
||||
"nvue": "-",
|
||||
"android": "√",
|
||||
"ios": "-",
|
||||
"harmony": "-"
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "√",
|
||||
"alipay": "-",
|
||||
"toutiao": "-",
|
||||
"baidu": "-",
|
||||
"kuaishou": "-",
|
||||
"jd": "-",
|
||||
"harmony": "-",
|
||||
"qq": "-",
|
||||
"lark": "-",
|
||||
"xhs": "-"
|
||||
},
|
||||
"quickapp": {
|
||||
"huawei": "-",
|
||||
"union": "-"
|
||||
}
|
||||
},
|
||||
"uni-app-x": {
|
||||
"web": {
|
||||
"safari": "-",
|
||||
"chrome": "-"
|
||||
},
|
||||
"app": {
|
||||
"android": "-",
|
||||
"ios": "-",
|
||||
"harmony": "-"
|
||||
},
|
||||
"mp": {
|
||||
"weixin": "-"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
145
uni_modules/xq-tree/readme.md
Normal file
145
uni_modules/xq-tree/readme.md
Normal file
@@ -0,0 +1,145 @@
|
||||
# xq-tree 树形控件
|
||||
|
||||
基于 uni-app 的高性能树形控件,支持**手风琴模式**、**多选/单选**、**父子联动**、**严格模式**、**自定义节点插槽**、**交错展开折叠动画**及**固定宽高滚动**,完美兼容微信小程序、H5 及 App。
|
||||
|
||||
---
|
||||
|
||||
## ✨ 核心特性
|
||||
|
||||
- ✅ **手风琴模式**:同级节点自动折叠,保持界面整洁
|
||||
- ✅ **多选支持**:复选框半选/全选状态,父子联动或严格模式自由切换
|
||||
- ✅ **单选模式**:`multiple` 为 false 时仅允许选中一个节点
|
||||
- ✅ **自定义节点内容**:通过插槽完全控制节点渲染,包括展开图标
|
||||
- ✅ **交错动画**:展开/折叠时子节点有平滑的下落/收起动画,无空白闪烁
|
||||
- ✅ **固定宽高滚动**:通过 `height`/`width` 属性限制容器,溢出自动出现滚动条
|
||||
- ✅ **灵活字段映射**:自定义 `nodeKey`、`labelKey`、`childrenKey` 等字段名
|
||||
- ✅ **丰富的方法**:全选、清空、指定节点选中、获取选中节点等
|
||||
- ✅ **深层递归操作**:`expandAll` 等方法支持递归展开整棵树
|
||||
|
||||
---
|
||||
|
||||
## 📦 安装与引入
|
||||
|
||||
将组件文件夹放入 `uni_modules/xq-tree/` 或 `components/` 目录,即可通过 easycom 自动引入,无需手动 import。
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速上手
|
||||
|
||||
```vue
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const treeData = ref([
|
||||
{
|
||||
id: 1,
|
||||
name: '一级 1',
|
||||
children: [
|
||||
{ id: 2, name: '二级 1-1' },
|
||||
{ id: 3, name: '二级 1-2' }
|
||||
]
|
||||
}
|
||||
])
|
||||
|
||||
function handleCheck(node, checkedState) {
|
||||
console.log('当前节点:', node)
|
||||
console.log('选中状态:', checkedState)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<xq-tree
|
||||
:data="treeData"
|
||||
node-key="id"
|
||||
label-key="name"
|
||||
show-checkbox
|
||||
default-expand-all
|
||||
@check="handleCheck"
|
||||
/>
|
||||
</template>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| data | Array | `[]` | 树形数据源 |
|
||||
| nodeKey | String | `'id'` | 节点唯一标识字段 |
|
||||
| labelKey | String | `'label'` | 节点显示文本字段 |
|
||||
| childrenKey | String | `'children'` | 子节点字段名 |
|
||||
| indent | Number | `20` | 每级缩进像素 |
|
||||
| showCheckbox | Boolean | `false` | 是否显示复选框 |
|
||||
| defaultExpandAll | Boolean | `false` | 是否默认展开所有节点 |
|
||||
| defaultExpandedKeys | Array | `[]` | 默认展开的节点 key 数组 |
|
||||
| defaultCheckedKeys | Array | `[]` | 默认勾选的节点 key 数组 |
|
||||
| checkStrictly | Boolean | `true` | 是否严格模式(父子互不关联) |
|
||||
| accordion | Boolean | `false` | 是否开启手风琴模式 |
|
||||
| multiple | Boolean | `false` | 是否开启多选(false 时为单选模式)本期并未实现 |
|
||||
| isDeepExpand | Boolean | `true` | 调用 expandAll 时是否递归展开所有子节点 |
|
||||
| emptyText | String | `'暂无数据'` | 空数据时显示的文本 |
|
||||
| checkedKey | String | `'_checked'` | 节点数据中存储勾选状态的字段名 |
|
||||
| height | Number | `500` | 容器最小高度(rpx),溢出时垂直滚动 |
|
||||
| width | Number | `300` | 容器最小宽度(rpx),溢出时水平滚动 |
|
||||
| labelSlot | Boolean | `false` | 微信小程序是否启用自定义节点插槽 |
|
||||
|
||||
---
|
||||
|
||||
## 📤 Events
|
||||
|
||||
| 事件名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| node-click | `(node)` | 节点被点击时触发 |
|
||||
| node-expand | `(node)` | 节点展开时触发 |
|
||||
| node-collapse | `(node)` | 节点折叠时触发 |
|
||||
| check-change | `(node, checked)` | 复选框状态改变时触发 |
|
||||
| check | `(node, { checkedKeys })` | 点击复选框后触发,返回当前所有选中 keys |
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Slots
|
||||
|
||||
| 插槽名 | 作用域 | 说明 |
|
||||
|--------|--------|------|
|
||||
| node | `{ node, level, expanded, checked, indeterminate, isLeaf }` | 自定义节点内容(需设置 `labelSlot` 为 true 在微信小程序中启用) |
|
||||
| icon | `{ node }` | 自定义展开/折叠图标(微信小程序不支持) |
|
||||
|
||||
> ℹ️ 微信小程序中,由于性能问题,`node` 插槽需手动设置 `labelSlot` 为 `true` 才会启用,否则默认渲染纯文本。
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 方法 (通过 ref 调用)
|
||||
|
||||
| 方法名 | 参数 | 说明 |
|
||||
|--------|------|------|
|
||||
| getCheckedKeys() | - | 返回当前所有勾选的节点 key 数组 |
|
||||
| getCheckedNodes() | - | 返回当前所有勾选的节点原始数据数组 |
|
||||
| setCheckedKeys(keys) | `keys: Array` | 设置当前勾选的节点 key 数组 |
|
||||
| expandAll() | - | 展开所有节点(根据 `isDeepExpand` 决定是否递归) |
|
||||
| collapseAll() | - | 折叠所有节点 |
|
||||
| clearChecked() | - | 清空所有勾选 |
|
||||
| setNodeChecked(keyOrNode, checked) | `keyOrNode`:节点的 key 或节点对象<br>`checked`:布尔值 | 设置指定节点的勾选状态 |
|
||||
| checkAll() | - | 全选所有节点 |
|
||||
|
||||
---
|
||||
|
||||
## 🎨 动画说明
|
||||
|
||||
组件内置了基于 CSS 关键帧的进入/离开动画,通过 `TransitionGroup` 实现:
|
||||
|
||||
- **展开动画**:子节点从上方逐渐撑开并淡入,同级子节点有 0.03s 的错开延迟
|
||||
- **折叠动画**:节点向上收起并淡出,动画完成后自动移除
|
||||
|
||||
可通过覆盖 `.tree-node-enter-active`、`.tree-node-leave-active` 及 `@keyframes treeExpandIn`、`treeCollapseOut` 样式来调整动画时长或效果。
|
||||
|
||||
---
|
||||
|
||||
## 📄 License
|
||||
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0.0
|
||||
**最后更新**:2026-04-26
|
||||
Reference in New Issue
Block a user