一单四制优化及加入工作流

This commit is contained in:
王利强
2026-07-27 09:27:22 +08:00
parent 455fd4fc07
commit 7c3285ed5a
725 changed files with 19668 additions and 2921 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -0,0 +1,21 @@
## 1.0.22026-04-28
插槽去掉data字段
## 1.0.12026-04-26
预览图片
## 1.0.02026-04-26
## [1.0.0] - 2026-04-26
### 🚀 首次发布
-**树形展示**:基础树形列表渲染,支持无限层级数据展示
-**展开/折叠**:节点可展开折叠,支持深层递归展开所有节点
-**手风琴模式**:同级节点只保留一个展开,保持界面整洁
-**多选复选框**:显示复选框,支持严格模式(父子独立)和非严格模式(父子联动),父节点显示半选状态
-**单选模式**`multiple` 属性控制,关闭后仅允许选中一个节点
-**交错动画**:展开/折叠时子节点有平滑的下落和收起动画,视觉流畅
-**自定义节点内容**:通过 `#node` 插槽自定义节点内容,图标也可通过 `#icon` 插槽替换
-**固定宽高滚动**:通过 `height``width` 属性限制容器,内容溢出时自动出现滚动条
-**丰富的方法暴露**:提供全选、清空选中、指定节点选中、获取选中节点等方法
-**灵活字段映射**:支持自定义 `nodeKey``labelKey``childrenKey` 等字段名
-**微信小程序兼容**:小程序端使用纯文本箭头图标,性能优化,插槽可选择性开启
-**依赖声明**:插件根目录 `package.json` 中声明依赖 `@dcloudio/uni-ui`,导入时自动安装

View 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>

View 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": "-"
}
}
}
}
}
}

View 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