diff --git a/examples/Drop.vue b/examples/Drop.vue
index dbd4084..7b034c7 100644
--- a/examples/Drop.vue
+++ b/examples/Drop.vue
@@ -58,6 +58,22 @@
选中的值:{{ value2 }}
+
+
多选(保持顺序):
+
+
顺序值:{{ orderValue }}
+
@@ -91,6 +107,7 @@ export default defineComponent({
const data = ref(genData().data)
const value = ref('2')
const value2 = ref('2')
+ const orderValue = ref([])
const placement = ref('bottom-start')
function handleCheckedChange() {
console.log('checked-change')
@@ -108,6 +125,7 @@ export default defineComponent({
data,
value,
value2,
+ orderValue,
placement,
handleCheckedChange,
handleSelectedChange,
diff --git a/site/api/vtree.md b/site/api/vtree.md
index fe1aafa..1eadccc 100644
--- a/site/api/vtree.md
+++ b/site/api/vtree.md
@@ -40,6 +40,7 @@
| nodeIndent | 子节点缩进 | `number` | 20 |
| renderNodeAmount | 渲染节点数量,可见节点数大于此值且高度超过(容器可视高度能容纳节点数 + bufferNodeAmount)则不会渲染所有可见节点 | `number` | 100 |
| bufferNodeAmount | 当滚动到视野外的节点个数大于此值时刷新渲染节点 | `number` | 20 |
+| maintainCheckOrder `4.2.0` | 多选时保持选中的顺序 | `boolean` | false |
## VTree Events
diff --git a/site/en/api/vtree.md b/site/en/api/vtree.md
index 0eb7e9b..d337ab0 100644
--- a/site/en/api/vtree.md
+++ b/site/en/api/vtree.md
@@ -40,6 +40,7 @@
| nodeIndent | Child node indent | `number` | 20 |
| renderNodeAmount | Node amount to render. Not all visible nodes will be rendered when they are more than this prop and the height is more than (node amount the container clientHeight can hold + bufferNodeAmount) | `number` | 100 |
| bufferNodeAmount | Refresh render nodes when scrolled node amount is more than this prop | `number` | 20 |
+| maintainCheckOrder `4.2.0` | Maintain check order in multiple select mode | `boolean` | false |
## VTree Events
diff --git a/src/components/Tree.vue b/src/components/Tree.vue
index f18e206..22dda47 100644
--- a/src/components/Tree.vue
+++ b/src/components/Tree.vue
@@ -226,6 +226,11 @@ export interface TreeProps {
/** 当滚动到视野外的节点个数大于此值时刷新渲染节点 */
bufferNodeAmount?: number,
+
+ /**
+ * 多选时是否保持选中顺序
+ */
+ maintainCheckOrder?: boolean,
}
export const DEFAULT_TREE_PROPS = {
@@ -258,6 +263,7 @@ export const DEFAULT_TREE_PROPS = {
nodeIndent: 20,
renderNodeAmount: 100,
bufferNodeAmount: 20,
+ maintainCheckOrder: false,
}
@@ -314,7 +320,8 @@ const getInitialNonReactiveValues = (): INonReactiveData => {
cascade: props.cascade,
defaultExpandAll: props.defaultExpandAll,
load: props.load,
- expandOnFilter: props.expandOnFilter
+ expandOnFilter: props.expandOnFilter,
+ maintainCheckOrder: props.maintainCheckOrder,
}),
blockNodes: [] as TreeNode[]
}
diff --git a/src/store/tree-store.ts b/src/store/tree-store.ts
index 169641f..24d667c 100644
--- a/src/store/tree-store.ts
+++ b/src/store/tree-store.ts
@@ -14,6 +14,7 @@ interface ITreeStoreOptions {
defaultExpandAll?: boolean
load?: Function
expandOnFilter?: boolean
+ maintainCheckOrder?: boolean
}
interface IMapData {
@@ -46,6 +47,9 @@ export default class TreeStore extends TreeEventTarget {
/** 当前单选选中节点 key */
private currentSelectedKey: TreeNodeKeyType | null = null
+ /** 多选选中节点的顺序 */
+ private checkedNodesOrder: TreeNodeKeyType[] = []
+
//#endregion Properties
constructor(private readonly options: ITreeStoreOptions) {
@@ -74,6 +78,8 @@ export default class TreeStore extends TreeEventTarget {
for (let key in this.mapData) delete this.mapData[key]
// 扁平化之前清空单选选中,如果 value 有值,则是 selectableUnloadKey 有值,会重新设置 currentSelectedKey ;多选选中没有存储在 store 中,因此不必事先清空。
this.currentSelectedKey = null
+ // 清空选中顺序
+ this.checkedNodesOrder = []
// 扁平化节点数据
this.flatData = this.flattenData(this.data)
// 更新未载入多选选中节点
@@ -122,6 +128,20 @@ export default class TreeStore extends TreeEventTarget {
node.checked = value
}
+ // 更新选中顺序
+ if (value) {
+ // 如果节点被选中,将其添加到顺序数组的末尾
+ if (!this.checkedNodesOrder.includes(key)) {
+ this.checkedNodesOrder.push(key)
+ }
+ } else {
+ // 如果节点被取消选中,从顺序数组中移除
+ const index = this.checkedNodesOrder.indexOf(key)
+ if (index !== -1) {
+ this.checkedNodesOrder.splice(index, 1)
+ }
+ }
+
if (triggerEvent) {
if (node.checked) {
this.emit('check', node)
@@ -168,6 +188,8 @@ export default class TreeStore extends TreeEventTarget {
triggerEvent: boolean = true,
triggerDataChange: boolean = true
): void {
+ // 清空选中顺序
+ this.checkedNodesOrder = []
keys.forEach(key => {
this.setChecked(key, value, false, false)
})
@@ -222,6 +244,8 @@ export default class TreeStore extends TreeEventTarget {
})
// 清空未加载多选选中节点
this.unloadCheckedKeys = []
+ // 清空选中顺序
+ this.checkedNodesOrder = []
this.triggerCheckedChange(triggerEvent, triggerDataChange)
}
@@ -605,8 +629,10 @@ export default class TreeStore extends TreeEventTarget {
/**
* 获取多选选中节点
* @param ignoreMode 忽略模式,可选择忽略父节点或子节点,默认值是 VTree 的 ignoreMode Prop
+ * @param maintainCheckOrder 是否保持选中顺序,默认为 false 以保持向后兼容性
*/
- getCheckedNodes(ignoreMode = this.options.ignoreMode): TreeNode[] {
+ getCheckedNodes(ignoreMode = this.options.ignoreMode, maintainCheckOrder = this.options.maintainCheckOrder): TreeNode[] {
+ let checkedNodes: TreeNode[]
if (ignoreMode === ignoreEnum.children) {
const result: TreeNode[] = []
const traversal = (nodes: TreeNode[]) => {
@@ -619,22 +645,36 @@ export default class TreeStore extends TreeEventTarget {
})
}
traversal(this.data)
- return result
+ checkedNodes = result
} else {
- return this.flatData.filter(node => {
+ checkedNodes = this.flatData.filter(node => {
if (ignoreMode === ignoreEnum.parents)
return node.checked && node.isLeaf
return node.checked
})
}
+
+ // 只有在需要保持选中顺序时才进行排序
+ if (maintainCheckOrder) {
+ return checkedNodes.sort((a, b) => {
+ const aIndex = this.checkedNodesOrder.indexOf(a[this.options.keyField])
+ const bIndex = this.checkedNodesOrder.indexOf(b[this.options.keyField])
+ if (aIndex === -1) return 1
+ if (bIndex === -1) return -1
+ return aIndex - bIndex
+ })
+ }
+
+ return checkedNodes
}
/**
* 获取多选选中的节点 key ,包括未加载的 key
* @param ignoreMode 忽略模式,同 `getCheckedNodes`
+ * @param maintainCheckOrder 是否保持选中顺序,默认为 false 以保持向后兼容性
*/
- getCheckedKeys(ignoreMode = this.options.ignoreMode): TreeNodeKeyType[] {
- return this.getCheckedNodes(ignoreMode)
+ getCheckedKeys(ignoreMode = this.options.ignoreMode, maintainCheckOrder = this.options.maintainCheckOrder): TreeNodeKeyType[] {
+ return this.getCheckedNodes(ignoreMode, maintainCheckOrder)
.map(checkedNodes => checkedNodes[this.options.keyField])
.concat(this.unloadCheckedKeys)
}
@@ -1223,6 +1263,19 @@ export default class TreeStore extends TreeEventTarget {
//#region Check nodes
+ /**
+ * 递归收集节点及其所有子节点的 key,父节点在前,子节点在后
+ */
+ private collectAllKeys(node: TreeNode): TreeNodeKeyType[] {
+ const keys: TreeNodeKeyType[] = [node[this.options.keyField]]
+ if (node.children && node.children.length) {
+ node.children.forEach(child => {
+ keys.push(...this.collectAllKeys(child))
+ })
+ }
+ return keys
+ }
+
/**
* 向下勾选/取消勾选节点,包括自身
* @param node 需要向下勾选的节点
@@ -1234,6 +1287,21 @@ export default class TreeStore extends TreeEventTarget {
value: boolean,
filtering: boolean = false
): void {
+ // cascade 模式下,父节点整体操作 checkedNodesOrder
+ if (this.options.cascade && !node._parent) {
+ const keys = this.collectAllKeys(node)
+ if (value) {
+ // 整体插入末尾,去重
+ keys.forEach(key => {
+ if (!this.checkedNodesOrder.includes(key)) {
+ this.checkedNodesOrder.push(key)
+ }
+ })
+ } else {
+ // 整体移除
+ this.checkedNodesOrder = this.checkedNodesOrder.filter(key => !keys.includes(key))
+ }
+ }
node.children.forEach(child => {
this.checkNodeDownward(child, value, filtering)
})
@@ -1248,6 +1316,21 @@ export default class TreeStore extends TreeEventTarget {
return
node.checked = value
node.indeterminate = false
+
+ // 非 cascade 或子节点单独勾选时,单独插入末尾,去重
+ if (!this.options.cascade || node._parent) {
+ const key = node[this.options.keyField]
+ if (value) {
+ if (!this.checkedNodesOrder.includes(key)) {
+ this.checkedNodesOrder.push(key)
+ }
+ } else {
+ const index = this.checkedNodesOrder.indexOf(key)
+ if (index !== -1) {
+ this.checkedNodesOrder.splice(index, 1)
+ }
+ }
+ }
}
} else {
this.checkParentNode(node)
diff --git a/tests/unit/tree.spec.ts b/tests/unit/tree.spec.ts
index f342c34..31f79f2 100644
--- a/tests/unit/tree.spec.ts
+++ b/tests/unit/tree.spec.ts
@@ -463,6 +463,144 @@ describe('树多选测试', () => {
expect(wrapper.emitted()['update:modelValue'].length).toBe(1)
}))
+
+ it('保持选中顺序', () => new Promise(done => {
+ const data = genData({ treeDepth: 1 }).data
+ const wrapper = mount(VTree as any, {
+ propsData: {
+ data,
+ checkable: true
+ }
+ })
+ const vm = wrapper.vm
+
+ vm.$nextTick(() => {
+ const treeNodes: any[] = wrapper.findAllComponents({
+ name: 'VTreeNode'
+ }) as any[]
+
+ // 按顺序选中节点
+ treeNodes[4].find('.vtree-tree-node__checkbox').trigger('click')
+ treeNodes[0].find('.vtree-tree-node__checkbox').trigger('click')
+ treeNodes[2].find('.vtree-tree-node__checkbox').trigger('click')
+
+ vm.$nextTick(() => {
+ // 获取选中节点,不保持顺序
+ const unorderedNodes = (vm as any).nonReactive.store.getCheckedNodes()
+ const unorderedKeys = (vm as any).nonReactive.store.getCheckedKeys()
+
+ // 获取选中节点,保持顺序
+ const orderedNodes = (vm as any).nonReactive.store.getCheckedNodes(undefined, true)
+ const orderedKeys = (vm as any).nonReactive.store.getCheckedKeys(undefined, true)
+
+ // 验证节点数量
+ expect(unorderedNodes.length).toBe(orderedNodes.length)
+ expect(unorderedKeys.length).toBe(orderedKeys.length)
+
+ // 验证顺序
+ expect(orderedNodes[0].id).toBe(data[4].id)
+ expect(orderedNodes[1].id).toBe(data[0].id)
+ expect(orderedNodes[2].id).toBe(data[2].id)
+ treeNodes[0].find('.vtree-tree-node__checkbox').trigger('click')
+
+ vm.$nextTick(() => {
+ // 验证取消选中后顺序更新
+ const updatedOrderedNodes = (vm as any).nonReactive.store.getCheckedNodes(undefined, true)
+ expect(updatedOrderedNodes.length).toBe(2)
+ expect(updatedOrderedNodes[0].id).toBe(data[4].id)
+ expect(updatedOrderedNodes[1].id).toBe(data[2].id)
+
+ done()
+ })
+ })
+ })
+ }))
+
+ it('级联选择时保持选中顺序', () => new Promise(done => {
+ const data = genData().data
+ const wrapper = mount(VTree as any, {
+ propsData: {
+ data,
+ checkable: true,
+ cascade: true
+ }
+ })
+ const vm = wrapper.vm
+
+ vm.$nextTick(() => {
+ const treeNodes: any[] = wrapper.findAllComponents({
+ name: 'VTreeNode'
+ }) as any[]
+
+ // 选中第一个父节点及其子节点
+ treeNodes[0].find('.vtree-tree-node__checkbox').trigger('click')
+ // 选中第二个父节点及其子节点
+ treeNodes[2].find('.vtree-tree-node__checkbox').trigger('click')
+
+ vm.$nextTick(() => {
+ const orderedNodes = (vm as any).nonReactive.store.getCheckedNodes(undefined, true)
+
+ // 验证第一个父节点及其子节点的顺序
+ const firstParentNode = orderedNodes.find(node => node.id === data[0].id)
+ const firstChildNodes = orderedNodes.filter(node =>
+ node.id !== data[0].id && flatten(data[0]).some(n => n.id === node.id)
+ )
+
+ // 验证第二个父节点及其子节点的顺序
+ const secondParentNode = orderedNodes.find(node => node.id === data[2].id)
+ const secondChildNodes = orderedNodes.filter(node =>
+ node.id !== data[2].id && flatten(data[2]).some(n => n.id === node.id)
+ )
+
+ // 验证第一个父节点在其子节点之前
+ const firstParentIndex = orderedNodes.indexOf(firstParentNode)
+ firstChildNodes.forEach(childNode => {
+ const childIndex = orderedNodes.indexOf(childNode)
+ expect(childIndex).toBeGreaterThan(firstParentIndex)
+ })
+
+ // 验证第二个父节点在其子节点之前
+ const secondParentIndex = orderedNodes.indexOf(secondParentNode)
+ secondChildNodes.forEach(childNode => {
+ const childIndex = orderedNodes.indexOf(childNode)
+ expect(childIndex).toBeGreaterThan(secondParentIndex)
+ })
+
+ // 验证两个父节点及其子节点的相对顺序
+ expect(secondParentIndex).toBeGreaterThan(firstParentIndex)
+ secondChildNodes.forEach(childNode => {
+ const childIndex = orderedNodes.indexOf(childNode)
+ expect(childIndex).toBeGreaterThan(firstParentIndex)
+ })
+
+ // 验证总节点数量 (每个父节点有 1 + 5 + 5*5 = 31 个子节点)
+ expect(orderedNodes.length).toBe(62) // 2个父节点各31个节点
+
+ done()
+ })
+ })
+ }))
+
+ it('批量设置选中状态时保持顺序', () => {
+ const data = genData({ treeDepth: 1 }).data
+ const wrapper = mount(VTree as any, {
+ propsData: {
+ data,
+ checkable: true
+ }
+ })
+ const vm = wrapper.vm
+
+ // 批量设置选中状态
+ const keys = [data[2].id, data[0].id, data[4].id]
+ ;(vm as any).nonReactive.store.setCheckedKeys(keys, true)
+
+ // 验证选中顺序
+ const orderedNodes = (vm as any).nonReactive.store.getCheckedNodes(undefined, true)
+ expect(orderedNodes[0].id).toBe(data[2].id)
+ expect(orderedNodes[1].id).toBe(data[0].id)
+ expect(orderedNodes[2].id).toBe(data[4].id)
+ })
})
describe('树远程测试', () => {