Jelajahi Sumber

添加 cl-tree 树形组件

icssoa 7 bulan lalu
induk
melakukan
73a472053f

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
 	"name": "cool-unix",
-	"version": "8.0.19",
+	"version": "8.0.20",
 	"license": "MIT",
 	"scripts": {
 		"build-ui": "node ./uni_modules/cool-ui/scripts/generate-types.js",

+ 6 - 0
pages.json

@@ -317,6 +317,12 @@
 					}
 				},
 				{
+					"path": "data/tree",
+					"style": {
+						"navigationBarTitleText": "Tree 树形结构"
+					}
+				},
+				{
 					"path": "status/badge",
 					"style": {
 						"navigationBarTitleText": "Badge 角标"

+ 373 - 0
pages/demo/data/tree.uvue

@@ -0,0 +1,373 @@
+<template>
+	<cl-page>
+		<view class="p-3">
+			<demo-item :label="t('树形结构')">
+				<cl-tree
+					ref="treeRef"
+					:list="list"
+					:icon="isCustomIcon ? 'add-circle-line' : 'arrow-right-s-fill'"
+					:expand-icon="isCustomIcon ? 'indeterminate-circle-line' : 'arrow-down-s-fill'"
+					:show-checkbox="true"
+					:check-strictly="checkStrictly"
+				></cl-tree>
+
+				<cl-list border :pt="{ className: 'mt-5' }">
+					<cl-list-item :label="t('父子关联')">
+						<cl-switch v-model="checkStrictly"></cl-switch>
+					</cl-list-item>
+
+					<cl-list-item :label="t('换个图标')">
+						<cl-switch v-model="isCustomIcon"></cl-switch>
+					</cl-list-item>
+				</cl-list>
+			</demo-item>
+
+			<demo-item :label="t('选中操作')">
+				<view class="mb-2">
+					<cl-button @tap="setChecked">{{ t("选中部分节点") }}</cl-button>
+				</view>
+
+				<view class="mb-2">
+					<cl-button @tap="getChecked">{{ t("获取选中节点") }}</cl-button>
+
+					<cl-text
+						v-if="checkedKeys.length > 0"
+						:pt="{
+							className: '!text-sm p-2'
+						}"
+						>{{ checkedKeys.join(", ") }}</cl-text
+					>
+				</view>
+
+				<view class="mb-2">
+					<cl-button @tap="getHalfChecked">{{ t("获取半选节点") }}</cl-button>
+
+					<cl-text
+						v-if="halfCheckedKeys.length > 0"
+						:pt="{
+							className: '!text-sm p-2'
+						}"
+						>{{ halfCheckedKeys.join(", ") }}</cl-text
+					>
+				</view>
+
+				<view class="mb-2">
+					<cl-button @tap="clearChecked">{{ t("清空选中") }}</cl-button>
+				</view>
+			</demo-item>
+
+			<demo-item :label="t('展开操作')">
+				<view class="mb-2">
+					<cl-button @tap="expand">{{ t("展开部分节点") }}</cl-button>
+				</view>
+
+				<view class="mb-2">
+					<cl-button @tap="getExpanded">{{ t("获取展开节点") }}</cl-button>
+
+					<cl-text
+						v-if="expandedKeys.length > 0"
+						:pt="{
+							className: '!text-sm p-2'
+						}"
+						>{{ expandedKeys.join(", ") }}</cl-text
+					>
+				</view>
+
+				<view class="mb-2">
+					<cl-button @tap="expandAll">{{ t("展开所有") }}</cl-button>
+				</view>
+
+				<view class="mb-2">
+					<cl-button @tap="collapseAll">{{ t("收起所有") }}</cl-button>
+				</view>
+			</demo-item>
+		</view>
+	</cl-page>
+</template>
+
+<script lang="ts" setup>
+import DemoItem from "../components/item.uvue";
+import { t } from "@/locale";
+import { ref } from "vue";
+import { useTree } from "@/uni_modules/cool-ui";
+
+const list = useTree([
+	{
+		id: "1",
+		label: "华为",
+		children: [
+			{
+				id: "1-1",
+				label: "手机",
+				children: [
+					{
+						id: "1-1-1",
+						label: "Mate系列",
+						children: [
+							{
+								id: "1-1-1-1",
+								label: "Mate 50"
+							},
+							{
+								id: "1-1-1-2",
+								label: "Mate 40"
+							},
+							{
+								id: "1-1-1-3",
+								label: "Mate 30"
+							}
+						]
+					},
+					{
+						id: "1-1-2",
+						label: "P系列",
+						children: [
+							{
+								id: "1-1-2-1",
+								label: "P60"
+							},
+							{
+								id: "1-1-2-2",
+								label: "P50"
+							},
+							{
+								id: "1-1-2-3",
+								label: "P40"
+							}
+						]
+					}
+				]
+			},
+			{
+				id: "1-2",
+				label: "笔记本",
+				children: [
+					{
+						id: "1-2-1",
+						label: "MateBook X",
+						children: [
+							{
+								id: "1-2-1-1",
+								label: "MateBook X Pro"
+							},
+							{
+								id: "1-2-1-2",
+								label: "MateBook X 2022"
+							}
+						]
+					},
+					{
+						id: "1-2-2",
+						label: "MateBook D",
+						children: [
+							{
+								id: "1-2-2-1",
+								label: "MateBook D 14"
+							},
+							{
+								id: "1-2-2-2",
+								label: "MateBook D 15"
+							}
+						]
+					},
+					{
+						id: "1-2-3",
+						label: "MateBook 13"
+					}
+				]
+			}
+		]
+	},
+	{
+		id: "2",
+		label: "小米",
+		children: [
+			{
+				id: "2-1",
+				label: "手机",
+				children: [
+					{
+						id: "2-1-1",
+						label: "小米数字系列"
+					},
+					{
+						id: "2-1-2",
+						label: "Redmi系列"
+					}
+				]
+			},
+			{
+				id: "2-2",
+				label: "家电",
+				children: [
+					{
+						id: "2-2-1",
+						label: "电视"
+					},
+					{
+						id: "2-2-2",
+						label: "空调"
+					}
+				]
+			}
+		]
+	},
+	{
+		id: "3",
+		label: "苹果",
+		children: [
+			{
+				id: "3-1",
+				label: "手机",
+				children: [
+					{
+						id: "3-1-1",
+						label: "iPhone 14"
+					},
+					{
+						id: "3-1-2",
+						label: "iPhone 13"
+					}
+				]
+			},
+			{
+				id: "3-2",
+				label: "平板",
+				children: [
+					{
+						id: "3-2-1",
+						label: "iPad Pro"
+					},
+					{
+						id: "3-2-2",
+						label: "iPad Air"
+					}
+				]
+			}
+		]
+	},
+	{
+		id: "4",
+		label: "OPPO",
+		children: [
+			{
+				id: "4-1",
+				label: "手机",
+				children: [
+					{
+						id: "4-1-1",
+						label: "Find系列"
+					},
+					{
+						id: "4-1-2",
+						label: "Reno系列"
+					}
+				]
+			},
+			{
+				id: "4-2",
+				label: "配件",
+				children: [
+					{
+						id: "4-2-1",
+						label: "耳机"
+					},
+					{
+						id: "4-2-2",
+						label: "手环"
+					}
+				]
+			}
+		]
+	},
+	{
+		id: "5",
+		label: "vivo",
+		children: [
+			{
+				id: "5-1",
+				label: "手机",
+				children: [
+					{
+						id: "5-1-1",
+						label: "X系列"
+					},
+					{
+						id: "5-1-2",
+						label: "S系列"
+					}
+				]
+			},
+			{
+				id: "5-2",
+				label: "智能设备",
+				children: [
+					{
+						id: "5-2-1",
+						label: "手表"
+					},
+					{
+						id: "5-2-2",
+						label: "耳机"
+					}
+				]
+			}
+		]
+	}
+]);
+
+// 是否严格的遵循父子不互相关联
+const checkStrictly = ref(false);
+
+// 是否自定义图标
+const isCustomIcon = ref(false);
+
+// 树形组件引用
+const treeRef = ref<ClTreeComponentPublicInstance | null>(null);
+
+// 选中节点的keys
+const checkedKeys = ref<(string | number)[]>([]);
+
+// 半选节点的keys
+const halfCheckedKeys = ref<(string | number)[]>([]);
+
+// 展开节点的keys
+const expandedKeys = ref<(string | number)[]>([]);
+
+// 演示方法
+function setChecked() {
+	treeRef.value!.setCheckedKeys(["1-1", "2"]);
+}
+
+function getChecked() {
+	checkedKeys.value = treeRef.value!.getCheckedKeys();
+}
+
+function clearChecked() {
+	treeRef.value!.clearChecked();
+	checkedKeys.value = [];
+	halfCheckedKeys.value = [];
+}
+
+function getHalfChecked() {
+	halfCheckedKeys.value = treeRef.value!.getHalfCheckedKeys();
+}
+
+function expand() {
+	treeRef.value!.setExpandedKeys(["1", "1-1", "2"]);
+}
+
+function getExpanded() {
+	expandedKeys.value = treeRef.value!.getExpandedKeys();
+}
+
+function expandAll() {
+	treeRef.value!.expandAll();
+	expandedKeys.value = treeRef.value!.getExpandedKeys();
+}
+
+function collapseAll() {
+	treeRef.value!.collapseAll();
+	expandedKeys.value = [];
+}
+</script>

+ 5 - 0
pages/index/home.uvue

@@ -322,6 +322,11 @@ const data = computed<Item[]>(() => {
 					label: t("筛选栏"),
 					icon: "filter-line",
 					path: "/pages/demo/data/filter-bar"
+				},
+				{
+					label: t("树形结构"),
+					icon: "node-tree",
+					path: "/pages/demo/data/tree"
 				}
 			]
 		},

+ 179 - 0
uni_modules/cool-ui/components/cl-tree-item/cl-tree-item.uvue

@@ -0,0 +1,179 @@
+<template>
+	<view class="cl-tree-item__wrapper" :class="[`cl-tree-item--level-${level}`, pt.className]">
+		<view
+			class="cl-tree-item"
+			:class="[
+				{
+					'is-expand': hover,
+					'is-dark': isDark
+				},
+				pt.className
+			]"
+			@touchstart="onTouchStart"
+			@touchend="onTouchEnd"
+			@touchcancel="onTouchEnd"
+		>
+			<view class="cl-tree-item__expand" :class="[pt.expand?.className]">
+				<cl-icon
+					:name="icon"
+					:size="pt.expandIcon?.size ?? 36"
+					:color="pt.expandIcon?.color"
+					:pt="{
+						className: pt.expandIcon?.className
+					}"
+					v-if="hasChildren"
+				></cl-icon>
+			</view>
+
+			<cl-text
+				:pt="{
+					className: parseClass(['flex-1 mx-1', pt.label?.className])
+				}"
+				>{{ item.label }}</cl-text
+			>
+
+			<template v-if="ClTree?.showCheckbox">
+				<view
+					class="cl-tree-item__checkbox"
+					:class="[pt.checkbox?.className]"
+					@touchstart.stop="toChecked"
+				>
+					<cl-icon
+						v-if="item.isChecked"
+						:name="pt.checkedIcon?.name ?? 'checkbox-circle-fill'"
+						:size="pt.checkedIcon?.size ?? 38"
+						:color="pt.checkedIcon?.color ?? 'primary'"
+					></cl-icon>
+					<cl-icon
+						v-else-if="item.isHalfChecked"
+						:name="pt.halfCheckedIcon?.name ?? 'indeterminate-circle-line'"
+						:size="pt.halfCheckedIcon?.size ?? 38"
+						:color="pt.halfCheckedIcon?.color ?? 'primary'"
+					></cl-icon>
+					<cl-icon
+						v-else
+						:name="pt.uncheckedIcon?.name ?? 'checkbox-blank-circle-line'"
+						:size="pt.uncheckedIcon?.size ?? 38"
+						:color="pt.uncheckedIcon?.color ?? 'info'"
+					></cl-icon>
+				</view>
+			</template>
+		</view>
+
+		<template v-if="hasChildren && item.isExpand == true">
+			<cl-tree-item
+				v-for="item in item.children"
+				:key="item.id"
+				:item="item"
+				:level="level + 1"
+				:pt="props.pt"
+			></cl-tree-item>
+		</template>
+	</view>
+</template>
+
+<script lang="ts" setup>
+import { computed, ref, type PropType } from "vue";
+import { isDark, parseClass, parsePt, useParent } from "@/cool";
+import type { ClTreeItem, PassThroughProps } from "../../types";
+import type { ClIconProps } from "../cl-icon/props";
+
+defineOptions({
+	name: "cl-tree-item"
+});
+
+const props = defineProps({
+	pt: {
+		type: Object,
+		default: () => ({})
+	},
+	item: {
+		type: Object as PropType<ClTreeItem>,
+		default: () => ({})
+	},
+	level: {
+		type: Number,
+		default: 0
+	}
+});
+
+// 透传属性类型定义,支持自定义各部分样式和图标
+type PassThrough = {
+	className?: string; // 外层自定义类名
+	wrapper?: PassThroughProps; // 外层包裹属性
+	expand?: PassThroughProps; // 展开区域属性
+	expandIcon?: ClIconProps; // 展开图标属性
+	checkbox?: PassThroughProps; // 复选框区域属性
+	checkedIcon?: ClIconProps; // 选中图标属性
+	halfCheckedIcon?: ClIconProps; // 半选图标属性
+	uncheckedIcon?: ClIconProps; // 未选中图标属性
+	label?: PassThroughProps; // 标签属性
+};
+
+// 解析pt透传属性,便于自定义样式和图标
+const pt = computed(() => parsePt<PassThrough>(props.pt));
+
+// 获取父级cl-tree组件实例,用于调用树的相关方法
+const ClTree = useParent<ClTreeComponentPublicInstance>("cl-tree");
+
+// 判断当前节点是否有子节点
+const hasChildren = computed(() => props.item.children != null && props.item.children.length > 0);
+
+// 计算当前节点应显示的图标(展开/收起)
+const icon = computed(() => {
+	if (ClTree == null) {
+		return "";
+	}
+	return props.item.isExpand == true ? ClTree.expandIcon : ClTree.icon;
+});
+
+// 切换当前节点的展开状态
+function toExpand() {
+	ClTree!.setExpanded(props.item.id, !(props.item.isExpand ?? false));
+}
+
+// 切换当前节点的选中状态
+function toChecked() {
+	ClTree!.setChecked(props.item.id, !(props.item.isChecked ?? false));
+}
+
+// 控制节点按下时的hover状态
+const hover = ref(false);
+
+// 触摸开始时触发,设置hover并展开/收起
+function onTouchStart() {
+	hover.value = true;
+	toExpand();
+}
+
+// 触摸结束时触发,取消hover
+function onTouchEnd() {
+	hover.value = false;
+}
+</script>
+
+<style lang="scss" scoped>
+.cl-tree-item__wrapper {
+	@apply pl-8;
+}
+
+.cl-tree-item {
+	@apply flex flex-row items-center w-full p-2 rounded-lg;
+
+	&__expand {
+		@apply w-6 flex items-center justify-center;
+	}
+
+	&.is-expand {
+		@apply bg-surface-50;
+
+		&.is-dark {
+			@apply bg-surface-700;
+		}
+	}
+
+	&--level-0 {
+		@apply pl-0;
+	}
+}
+</style>

+ 21 - 0
uni_modules/cool-ui/components/cl-tree-item/props.ts

@@ -0,0 +1,21 @@
+import type { ClTreeItem, PassThroughProps } from "../../types";
+import type { ClIconProps } from "../cl-icon/props";
+
+export type ClTreeItemPassThrough = {
+	className?: string;
+	wrapper?: PassThroughProps;
+	expand?: PassThroughProps;
+	expandIcon?: ClIconProps;
+	checkbox?: PassThroughProps;
+	checkedIcon?: ClIconProps;
+	halfCheckedIcon?: ClIconProps;
+	uncheckedIcon?: ClIconProps;
+	label?: PassThroughProps;
+};
+
+export type ClTreeItemProps = {
+	className?: string;
+	pt?: ClTreeItemPassThrough;
+	item?: ClTreeItem;
+	level?: number;
+};

+ 491 - 0
uni_modules/cool-ui/components/cl-tree/cl-tree.uvue

@@ -0,0 +1,491 @@
+<template>
+	<view class="cl-tree">
+		<cl-tree-item
+			v-for="item in data"
+			:key="item.id"
+			:item="item"
+			:level="0"
+			:pt="pt"
+		></cl-tree-item>
+	</view>
+</template>
+
+<script lang="ts" setup>
+import { computed, watch, ref, type PropType } from "vue";
+import type { ClTreeItem, ClTreeNodeInfo } from "../../types";
+
+defineOptions({
+	name: "cl-tree"
+});
+
+const props = defineProps({
+	pt: {
+		type: Object as PropType<any>,
+		default: () => ({})
+	},
+	// 树形结构数据
+	list: {
+		type: Array as PropType<ClTreeItem[]>,
+		default: () => []
+	},
+	// 节点图标
+	icon: {
+		type: String,
+		default: "arrow-right-s-fill"
+	},
+	// 展开图标
+	expandIcon: {
+		type: String,
+		default: "arrow-down-s-fill"
+	},
+	// 是否显示复选框
+	showCheckbox: {
+		type: Boolean,
+		default: false
+	},
+	// 是否严格的遵循父子不互相关联
+	checkStrictly: {
+		type: Boolean,
+		default: false
+	}
+});
+
+// 树数据
+const data = ref<ClTreeItem[]>(props.list);
+
+/**
+ * 优化的节点搜索算法,使用Map缓存提升查找性能
+ * 创建节点索引映射,O(1)时间复杂度查找节点
+ */
+const nodeMap = computed(() => {
+	// 创建一个Map用于存储节点信息,key为节点id,value为节点信息对象
+	const map = new Map<string | number, ClTreeNodeInfo>();
+
+	// 递归遍历所有节点,构建节点与父节点的映射关系
+	function buildMap(nodes: ClTreeItem[], parent: ClTreeItem | null = null): void {
+		for (let i: number = 0; i < nodes.length; i++) {
+			const node = nodes[i]; // 当前节点
+
+			// 将当前节点的信息存入map,包含节点本身、父节点、在父节点中的索引
+			map.set(node.id, { node, parent, index: i } as ClTreeNodeInfo);
+
+			// 如果当前节点有子节点,则递归处理子节点
+			if (node.children != null && node.children.length > 0) {
+				buildMap(node.children, node);
+			}
+		}
+	}
+
+	// 从根节点开始构建映射
+	buildMap(data.value);
+	return map; // 返回构建好的Map
+});
+
+/**
+ * 根据key查找节点信息
+ * @param key 节点id
+ * @returns 节点信息对象或null
+ */
+function findNodeInfo(key: string | number): ClTreeNodeInfo | null {
+	const result = nodeMap.value.get(key); // 从Map中查找节点信息
+	return result != null ? result : null; // 找到则返回,否则返回null
+}
+
+/**
+ * 获取指定节点的所有祖先节点
+ * @param key 节点id
+ * @returns 祖先节点数组(从根到最近父节点顺序)
+ */
+function getAncestors(key: string | number): ClTreeItem[] {
+	const result: ClTreeItem[] = []; // 用于存储祖先节点
+	let nodeInfo = findNodeInfo(key); // 当前节点信息
+
+	// 循环查找父节点,直到根节点
+	while (nodeInfo != null && nodeInfo.parent != null) {
+		result.unshift(nodeInfo.parent); // 将父节点插入到数组前面
+		nodeInfo = findNodeInfo(nodeInfo.parent.id); // 查找父节点信息
+	}
+
+	return result; // 返回祖先节点数组
+}
+
+/**
+ * 更新所有节点的选中状态(用于批量操作后的状态同步)
+ */
+function updateAllCheckStates(): void {
+	// 递归更新每个节点的选中和半选状态
+	function updateNodeStates(nodes: ClTreeItem[]): void {
+		for (let i: number = 0; i < nodes.length; i++) {
+			const node = nodes[i]; // 当前节点
+			const children = node.children != null ? node.children : []; // 子节点数组
+
+			if (children.length == 0) {
+				// 叶子节点,重置半选状态
+				node.isHalfChecked = false;
+				continue; // 跳过后续处理
+			}
+
+			// 先递归处理子节点
+			updateNodeStates(children);
+
+			// 统计子节点的选中和半选数量
+			let checkedCount = 0; // 选中数量
+			let halfCheckedCount = 0; // 半选数量
+
+			for (let j = 0; j < children.length; j++) {
+				if (children[j].isChecked == true) {
+					checkedCount++;
+				} else if (children[j].isHalfChecked == true) {
+					halfCheckedCount++;
+				}
+			}
+
+			// 根据子节点状态更新当前节点状态
+			if (checkedCount == children.length) {
+				// 全部选中
+				node.isChecked = true;
+				node.isHalfChecked = false;
+			} else if (checkedCount > 0 || halfCheckedCount > 0) {
+				// 部分选中或有半选
+				node.isChecked = false;
+				node.isHalfChecked = true;
+			} else {
+				// 全部未选中
+				node.isChecked = false;
+				node.isHalfChecked = false;
+			}
+		}
+	}
+
+	// 从根节点开始递归更新
+	updateNodeStates(data.value);
+}
+
+/**
+ * 更新指定节点的所有祖先节点的选中状态
+ * @param key 节点id
+ */
+function updateAncestorsCheckState(key: string | number): void {
+	const ancestors = getAncestors(key); // 获取所有祖先节点
+
+	// 从最近的父节点开始向上更新
+	for (let i = ancestors.length - 1; i >= 0; i--) {
+		const ancestor = ancestors[i]; // 当前祖先节点
+		const children = ancestor.children != null ? ancestor.children : ([] as ClTreeItem[]); // 子节点数组
+
+		if (children.length == 0) continue; // 没有子节点则跳过
+
+		let checkedCount = 0; // 选中数量
+		let halfCheckedCount = 0; // 半选数量
+
+		// 统计子节点的选中和半选数量
+		for (let j = 0; j < children.length; j++) {
+			if (children[j].isChecked == true) {
+				checkedCount++;
+			} else if (children[j].isHalfChecked == true) {
+				halfCheckedCount++;
+			}
+		}
+
+		// 根据子节点状态更新当前祖先节点状态
+		if (checkedCount == children.length) {
+			// 全部选中
+			ancestor.isChecked = true;
+			ancestor.isHalfChecked = false;
+		} else if (checkedCount > 0 || halfCheckedCount > 0) {
+			// 部分选中或有半选
+			ancestor.isChecked = false;
+			ancestor.isHalfChecked = true;
+		} else {
+			// 全部未选中
+			ancestor.isChecked = false;
+			ancestor.isHalfChecked = false;
+		}
+	}
+}
+
+/**
+ * 获取指定节点的所有子孙节点
+ * 优化:使用队列实现广度优先遍历,避免递归栈溢出
+ * @param key 节点id
+ * @returns 子孙节点数组
+ */
+function getDescendants(key: string | number): ClTreeItem[] {
+	const nodeInfo = findNodeInfo(key); // 查找节点信息
+	if (nodeInfo == null || nodeInfo.node.children == null) {
+		return []; // 节点不存在或无子节点,返回空数组
+	}
+
+	// 存储所有子孙节点
+	const result: ClTreeItem[] = [];
+
+	// 队列用于广度优先遍历
+	const queue: ClTreeItem[] = [];
+
+	// 将子节点添加到队列中
+	for (let i = 0; i < nodeInfo.node.children.length; i++) {
+		queue.push(nodeInfo.node.children[i]);
+	}
+
+	// 广度优先遍历所有子孙节点
+	while (queue.length > 0) {
+		const node = queue.shift(); // 取出队首节点
+
+		if (node == null) break; // 队列为空则结束
+
+		result.push(node); // 将当前节点加入结果数组
+
+		// 如果有子节点,继续加入队列
+		if (node.children != null && node.children.length > 0) {
+			for (let i = 0; i < node.children.length; i++) {
+				queue.push(node.children[i]);
+			}
+		}
+	}
+
+	return result; // 返回所有子孙节点
+}
+
+/**
+ * 清空所有节点的选中状态
+ */
+function clearChecked(): void {
+	// 遍历所有节点,将 isChecked 和 isHalfChecked 设为 false
+	nodeMap.value.forEach((info: ClTreeNodeInfo) => {
+		info.node.isChecked = false;
+		info.node.isHalfChecked = false;
+	});
+}
+
+/**
+ * 设置指定节点的选中状态
+ * @param key 节点id
+ * @param flag 是否选中
+ */
+function setChecked(key: string | number, flag: boolean): void {
+	const nodeInfo = findNodeInfo(key); // 查找节点信息
+	if (nodeInfo == null) return; // 节点不存在则返回
+
+	// 设置当前节点选中状态
+	nodeInfo.node.isChecked = flag;
+
+	// 非严格模式下处理父子联动
+	if (!props.checkStrictly) {
+		// 设置所有子孙节点的选中状态
+		const descendants = getDescendants(key);
+		for (let i = 0; i < descendants.length; i++) {
+			descendants[i].isChecked = flag;
+		}
+
+		// 更新所有祖先节点的状态
+		updateAncestorsCheckState(key);
+	}
+}
+
+/**
+ * 批量设置节点选中状态
+ * @param keys 需要设置为选中的节点id数组
+ */
+function setCheckedKeys(keys: (string | number)[]): void {
+	// 遍历所有需要选中的节点
+	for (let i = 0; i < keys.length; i++) {
+		const key: string | number = keys[i];
+		const nodeInfo = findNodeInfo(key); // 查找节点信息
+
+		if (nodeInfo != null) {
+			nodeInfo.node.isChecked = true; // 设置为选中
+
+			// 非严格模式下同时设置所有子孙节点为选中状态
+			if (!props.checkStrictly) {
+				const descendants = getDescendants(key);
+				for (let j = 0; j < descendants.length; j++) {
+					descendants[j].isChecked = true;
+				}
+			}
+		}
+	}
+
+	// 非严格模式下更新所有相关节点的状态
+	if (!props.checkStrictly) {
+		updateAllCheckStates();
+	}
+}
+
+/**
+ * 获取所有选中节点的keys
+ * @returns 选中节点id数组
+ */
+function getCheckedKeys(): (string | number)[] {
+	const result: (string | number)[] = []; // 存储选中节点id
+
+	/**
+	 * 递归收集所有选中节点的id
+	 * @param nodes 当前遍历的节点数组
+	 */
+	function collectCheckedKeys(nodes: ClTreeItem[]): void {
+		for (let i = 0; i < nodes.length; i++) {
+			const node = nodes[i];
+
+			if (node.isChecked == true) {
+				result.push(node.id); // 收集选中节点id
+			}
+
+			if (node.children != null) {
+				collectCheckedKeys(node.children); // 递归处理子节点
+			}
+		}
+	}
+
+	collectCheckedKeys(data.value); // 从根节点开始收集
+	return result; // 返回所有选中节点id
+}
+
+/**
+ * 获取所有半选中节点的keys
+ * @returns 半选节点id数组
+ */
+function getHalfCheckedKeys(): (string | number)[] {
+	const result: (string | number)[] = []; // 存储半选节点id
+
+	/**
+	 * 递归收集所有半选节点的id
+	 * @param nodes 当前遍历的节点数组
+	 */
+	function collectHalfCheckedKeys(nodes: ClTreeItem[]): void {
+		for (let i = 0; i < nodes.length; i++) {
+			const node = nodes[i];
+
+			if (node.isHalfChecked == true) {
+				result.push(node.id); // 收集半选节点id
+			}
+
+			if (node.children != null) {
+				collectHalfCheckedKeys(node.children); // 递归处理子节点
+			}
+		}
+	}
+
+	collectHalfCheckedKeys(data.value); // 从根节点开始收集
+	return result; // 返回所有半选节点id
+}
+
+/**
+ * 设置指定节点的展开状态
+ * @param key 节点id
+ * @param flag 是否展开
+ */
+function setExpanded(key: string | number, flag: boolean): void {
+	const nodeInfo = findNodeInfo(key); // 查找节点信息
+	if (nodeInfo == null) return; // 节点不存在则返回
+
+	nodeInfo.node.isExpand = flag; // 设置节点的展开状态
+}
+
+/**
+ * 批量设置节点展开状态
+ * @param keys 需要展开的节点id数组
+ */
+function setExpandedKeys(keys: (string | number)[]): void {
+	// 先重置所有节点为收起状态
+	nodeMap.value.forEach((info: ClTreeNodeInfo) => {
+		info.node.isExpand = false;
+	});
+
+	// 设置指定节点为展开状态
+	for (let i = 0; i < keys.length; i++) {
+		const nodeInfo = findNodeInfo(keys[i]);
+
+		if (nodeInfo != null) {
+			nodeInfo.node.isExpand = true;
+		}
+	}
+}
+
+/**
+ * 获取所有展开节点的keys
+ * @returns 展开节点id数组
+ */
+function getExpandedKeys(): (string | number)[] {
+	const result: (string | number)[] = []; // 存储展开节点id
+
+	/**
+	 * 递归收集所有展开节点的id
+	 * @param nodes 当前遍历的节点数组
+	 */
+	function collectExpandedKeys(nodes: ClTreeItem[]): void {
+		for (let i = 0; i < nodes.length; i++) {
+			const node = nodes[i];
+
+			if (node.isExpand == true) {
+				result.push(node.id); // 收集展开节点id
+			}
+
+			if (node.children != null) {
+				collectExpandedKeys(node.children); // 递归处理子节点
+			}
+		}
+	}
+
+	collectExpandedKeys(data.value); // 从根节点开始收集
+	return result; // 返回所有展开节点id
+}
+
+/**
+ * 展开所有节点
+ */
+function expandAll(): void {
+	// 遍历所有节点,如果有子节点则设置为展开
+	nodeMap.value.forEach((info: ClTreeNodeInfo) => {
+		if (info.node.children != null && info.node.children.length > 0) {
+			info.node.isExpand = true;
+		}
+	});
+}
+
+/**
+ * 收起所有节点
+ */
+function collapseAll(): void {
+	// 遍历所有节点,将isExpand设为false
+	nodeMap.value.forEach((info: ClTreeNodeInfo) => {
+		info.node.isExpand = false;
+	});
+}
+
+// 监听props.list变化,同步到内部数据
+watch(
+	computed(() => props.list),
+	(val: ClTreeItem[]) => {
+		data.value = val;
+	},
+	{ immediate: true, deep: true }
+);
+
+// 监听树数据变化,自动更新选中状态
+watch(
+	data,
+	() => {
+		if (!props.checkStrictly) {
+			updateAllCheckStates();
+		}
+	},
+	{ deep: true }
+);
+
+defineExpose({
+	icon: computed(() => props.icon),
+	expandIcon: computed(() => props.expandIcon),
+	showCheckbox: computed(() => props.showCheckbox),
+	checkStrictly: computed(() => props.checkStrictly),
+	clearChecked,
+	setChecked,
+	setCheckedKeys,
+	getCheckedKeys,
+	getHalfCheckedKeys,
+	setExpanded,
+	setExpandedKeys,
+	getExpandedKeys,
+	expandAll,
+	collapseAll
+});
+</script>

+ 11 - 0
uni_modules/cool-ui/components/cl-tree/props.ts

@@ -0,0 +1,11 @@
+import type { ClTreeItem, ClTreeNodeInfo } from "../../types";
+
+export type ClTreeProps = {
+	className?: string;
+	pt?: any;
+	list?: ClTreeItem[];
+	icon?: string;
+	expandIcon?: string;
+	showCheckbox?: boolean;
+	checkStrictly?: boolean;
+};

+ 10 - 1
uni_modules/cool-ui/hooks/component.ts

@@ -1,5 +1,5 @@
 import { parse } from "@/cool";
-import type { ClCascaderOption, ClListViewItem } from "../types";
+import type { ClCascaderOption, ClListViewItem, ClTreeItem } from "../types";
 
 export function useListView(data: UTSJSONObject[]) {
 	return data.map((e) => {
@@ -13,3 +13,12 @@ export function useListView(data: UTSJSONObject[]) {
 export function useCascader(data: UTSJSONObject[]) {
 	return data.map((e) => parse<ClCascaderOption>(e)!);
 }
+
+export function useTree(data: UTSJSONObject[]) {
+	return data.map((e) => {
+		return parse<ClTreeItem>({
+			...e,
+			value: e
+		})!;
+	});
+}

+ 5 - 1
uni_modules/cool-ui/index.d.ts

@@ -1,4 +1,4 @@
-import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps, Type, ClButtonType, Size, ClListViewItem, ClFilterItemType, ClSelectOption, ClFormLabelPosition, ClFormRule, ClFormValidateError, ClInputType, ClListItem, Justify, ClListViewGroup, ClListViewVirtualItem, ClListViewRefresherStatus, ClConfirmAction, ClConfirmOptions, ClToastOptions, ClPopupDirection, ClQrcodeMode, ClSelectDateShortcut, ClTabsItem, ClTextType, ClUploadItem } from "./types";
+import type { ClActionSheetItem, ClActionSheetOptions, PassThroughProps, Type, ClButtonType, Size, ClListViewItem, ClFilterItemType, ClSelectOption, ClFormLabelPosition, ClFormRule, ClFormValidateError, ClInputType, ClListItem, Justify, ClListViewGroup, ClListViewVirtualItem, ClListViewRefresherStatus, ClConfirmAction, ClConfirmOptions, ClToastOptions, ClPopupDirection, ClQrcodeMode, ClSelectDateShortcut, ClTabsItem, ClTextType, ClTreeItem, ClTreeNodeInfo, ClUploadItem } from "./types";
 import { type UiInstance } from "./hooks";
 import { type QrcodeOptions } from "./draw";
 
@@ -67,6 +67,8 @@ import type { ClTimelineProps, ClTimelinePassThrough } from "./components/cl-tim
 import type { ClTimelineItemProps, ClTimelineItemPassThrough } from "./components/cl-timeline-item/props";
 import type { ClToastProps } from "./components/cl-toast/props";
 import type { ClTopbarProps, ClTopbarPassThrough } from "./components/cl-topbar/props";
+import type { ClTreeProps } from "./components/cl-tree/props";
+import type { ClTreeItemProps, ClTreeItemPassThrough } from "./components/cl-tree-item/props";
 import type { ClUploadProps, ClUploadPassThrough } from "./components/cl-upload/props";
 import type { ClWaterfallProps, ClWaterfallPassThrough } from "./components/cl-waterfall/props";
 
@@ -140,6 +142,8 @@ declare module "vue" {
 		"cl-timeline-item": (typeof import('./components/cl-timeline-item/cl-timeline-item.uvue')['default']) & import('vue').DefineComponent<ClTimelineItemProps>;
 		"cl-toast": (typeof import('./components/cl-toast/cl-toast.uvue')['default']) & import('vue').DefineComponent<ClToastProps>;
 		"cl-topbar": (typeof import('./components/cl-topbar/cl-topbar.uvue')['default']) & import('vue').DefineComponent<ClTopbarProps>;
+		"cl-tree": (typeof import('./components/cl-tree/cl-tree.uvue')['default']) & import('vue').DefineComponent<ClTreeProps>;
+		"cl-tree-item": (typeof import('./components/cl-tree-item/cl-tree-item.uvue')['default']) & import('vue').DefineComponent<ClTreeItemProps>;
 		"cl-upload": (typeof import('./components/cl-upload/cl-upload.uvue')['default']) & import('vue').DefineComponent<ClUploadProps>;
 		"cl-waterfall": (typeof import('./components/cl-waterfall/cl-waterfall.uvue')['default']) & import('vue').DefineComponent<ClWaterfallProps>;
 	}

+ 18 - 0
uni_modules/cool-ui/types/component.d.ts

@@ -206,3 +206,21 @@ declare type ClSlideVerifyComponentPublicInstance = {
 	init: () => void;
 	reset: () => void;
 };
+
+declare type ClTreeComponentPublicInstance = {
+	icon: string;
+	expandIcon: string;
+	showCheckbox: boolean;
+	checkStrictly: boolean;
+	accordion: boolean;
+	clearChecked: () => void;
+	setChecked: (key: string | number, flag: boolean) => void;
+	setCheckedKeys: (keys: (string | number)[]) => void;
+	getCheckedKeys: () => (string | number)[];
+	getHalfCheckedKeys: () => (string | number)[];
+	setExpanded: (key: string | number, flag: boolean) => void;
+	setExpandedKeys: (keys: (string | number)[]) => void;
+	getExpandedKeys: () => (string | number)[];
+	expandAll: () => void;
+	collapseAll: () => void;
+};

+ 17 - 0
uni_modules/cool-ui/types/index.ts

@@ -181,3 +181,20 @@ export type ClFilterItem = {
 	type: ClFilterItemType;
 	options?: ClSelectOption[];
 };
+
+export type ClTreeItem = {
+	id: string | number;
+	label: string;
+	disabled?: boolean;
+	children?: ClTreeItem[];
+	value?: UTSJSONObject;
+	isExpand?: boolean;
+	isChecked?: boolean;
+	isHalfChecked?: boolean;
+};
+
+export type ClTreeNodeInfo = {
+	node: ClTreeItem;
+	parent?: ClTreeItem;
+	index: number;
+};