feat: role management page with component tree (#5675)

* feat: add shadcn tree

* fix: update vbenTree component

* feat: role management demo page

* feat: add cellSwitch renderer for vxeTable

* chore: remove tree examples
This commit is contained in:
Netfan
2025-03-07 16:03:08 +08:00
committed by GitHub
parent 4b9cfcb867
commit b37ed48b9d
19 changed files with 963 additions and 3 deletions

View File

@@ -27,3 +27,4 @@ export * from './textarea';
export * from './toggle';
export * from './toggle-group';
export * from './tooltip';
export * from './tree';

View File

@@ -0,0 +1,2 @@
export { default as VbenTree } from './tree.vue';
export type { FlattenedItem } from 'radix-vue';

View File

@@ -0,0 +1,301 @@
<script lang="ts" setup>
import type { Arrayable } from '@vueuse/core';
import type { FlattenedItem } from 'radix-vue';
import type { ClassType, Recordable } from '@vben-core/typings';
import { onMounted, ref, watch, watchEffect } from 'vue';
import { ChevronRight, IconifyIcon } from '@vben-core/icons';
import { cn, get } from '@vben-core/shared/utils';
import { useVModel } from '@vueuse/core';
import { TreeItem, TreeRoot } from 'radix-vue';
import { Checkbox } from '../checkbox';
interface TreeProps {
/** 单选时允许取消已有选项 */
allowClear?: boolean;
/** 显示边框 */
bordered?: boolean;
/** 取消父子关联选择 */
checkStrictly?: boolean;
/** 子级字段名 */
childrenField?: string;
/** 默认展开的键 */
defaultExpandedKeys?: Array<number | string>;
/** 默认展开的级别优先级高于defaultExpandedKeys */
defaultExpandedLevel?: number;
/** 默认值 */
defaultValue?: Arrayable<number | string>;
/** 禁用 */
disabled?: boolean;
/** 自定义节点类名 */
getNodeClass?: (item: FlattenedItem<Recordable<any>>) => string;
iconField?: string;
/** label字段 */
labelField?: string;
/** 当前值 */
modelValue?: Arrayable<number | string>;
/** 是否多选 */
multiple?: boolean;
/** 显示由iconField指定的图标 */
showIcon?: boolean;
/** 启用展开收缩动画 */
transition?: boolean;
/** 树数据 */
treeData: Recordable<any>[];
/** 值字段 */
valueField?: string;
}
const props = withDefaults(defineProps<TreeProps>(), {
allowClear: false,
bordered: false,
checkStrictly: false,
defaultExpandedKeys: () => [],
disabled: false,
expanded: () => [],
iconField: 'icon',
labelField: 'label',
modelValue: () => [],
multiple: false,
showIcon: true,
transition: false,
valueField: 'value',
childrenField: 'children',
});
const emits = defineEmits<{
expand: [value: FlattenedItem<Recordable<any>>];
select: [value: FlattenedItem<Recordable<any>>];
'update:modelValue': [value: Arrayable<Recordable<any>>];
}>();
interface InnerFlattenItem<T = Recordable<any>> {
hasChildren: boolean;
level: number;
value: T;
}
function flatten<T = Recordable<any>>(
items: T[],
childrenField: string = 'children',
level = 0,
): InnerFlattenItem<T>[] {
const result: InnerFlattenItem<T>[] = [];
items.forEach((item) => {
const children = get(item, childrenField) as Array<T>;
const val = {
hasChildren: Array.isArray(children) && children.length > 0,
level,
value: item,
};
result.push(val);
if (val.hasChildren)
result.push(...flatten(children, childrenField, level + 1));
});
return result;
}
const flattenData = ref<Array<InnerFlattenItem>>([]);
const modelValue = useVModel(props, 'modelValue', emits, {
deep: true,
defaultValue: props.defaultValue ?? [],
passive: (props.modelValue === undefined) as false,
});
const expanded = ref<Array<number | string>>(props.defaultExpandedKeys ?? []);
const treeValue = ref();
onMounted(() => {
watchEffect(() => {
flattenData.value = flatten(props.treeData, props.childrenField);
updateTreeValue();
if (
props.defaultExpandedLevel !== undefined &&
props.defaultExpandedLevel > 0
)
expandToLevel(props.defaultExpandedLevel);
});
});
function getItemByValue(value: number | string) {
return flattenData.value.find(
(item) => get(item.value, props.valueField) === value,
)?.value;
}
function updateTreeValue() {
const val = modelValue.value;
treeValue.value = Array.isArray(val)
? val.map((v) => getItemByValue(v))
: getItemByValue(val);
}
watch(
modelValue,
() => {
updateTreeValue();
},
{ deep: true, immediate: true },
);
function updateModelValue(val: Arrayable<Recordable<any>>) {
modelValue.value = Array.isArray(val)
? val.map((v) => get(v, props.valueField))
: get(val, props.valueField);
}
function expandToLevel(level: number) {
const keys: string[] = [];
flattenData.value.forEach((item) => {
if (item.level <= level - 1) {
keys.push(get(item.value, props.valueField));
}
});
expanded.value = keys;
}
function collapseNodes(value: Arrayable<number | string>) {
const keys = new Set(Array.isArray(value) ? value : [value]);
expanded.value = expanded.value.filter((key) => !keys.has(key));
}
function expandNodes(value: Arrayable<number | string>) {
const keys = [...(Array.isArray(value) ? value : [value])];
keys.forEach((key) => {
if (expanded.value.includes(key)) return;
const item = getItemByValue(key);
if (item) {
expanded.value.push(key);
}
});
}
function expandAll() {
expanded.value = flattenData.value
.filter((item) => item.hasChildren)
.map((item) => get(item.value, props.valueField));
}
function collapseAll() {
expanded.value = [];
}
function onToggle(item: FlattenedItem<Recordable<any>>) {
emits('expand', item);
}
function onSelect(item: FlattenedItem<Recordable<any>>) {
emits('select', item);
}
defineExpose({
collapseAll,
collapseNodes,
expandAll,
expandNodes,
expandToLevel,
getItemByValue,
});
</script>
<template>
<TreeRoot
:get-key="(item) => get(item, valueField)"
:get-children="(item) => get(item, childrenField)"
:items="treeData"
:model-value="treeValue"
v-model:expanded="expanded as string[]"
:default-expanded="defaultExpandedKeys as string[]"
:propagate-select="!checkStrictly"
:multiple="multiple"
:disabled="disabled"
:selection-behavior="allowClear || multiple ? 'toggle' : 'replace'"
@update:model-value="updateModelValue"
v-slot="{ flattenItems }"
:class="
cn(
'text-blackA11 select-none list-none rounded-lg p-2 text-sm font-medium',
$attrs.class as unknown as ClassType,
bordered ? 'border' : '',
)
"
>
<div class="w-full" v-if="$slots.header">
<slot name="header"> </slot>
</div>
<TreeItem
v-for="item in flattenItems"
v-slot="{
isExpanded,
isSelected,
isIndeterminate,
handleSelect,
handleToggle,
}"
:key="item._id"
:style="{ 'padding-left': `${item.level - 0.5}rem` }"
:class="
cn('cursor-pointer', getNodeClass?.(item), {
'data-[selected]:bg-accent': !multiple,
})
"
v-bind="item.bind"
@select="
(event) => {
if (event.detail.originalEvent.type === 'click') {
// event.preventDefault();
}
onSelect(item);
}
"
@toggle="
(event) => {
if (event.detail.originalEvent.type === 'click') {
event.preventDefault();
}
onToggle(item);
}
"
class="tree-node focus:ring-grass8 my-0.5 flex items-center rounded px-2 py-1 outline-none focus:ring-2"
>
<ChevronRight
v-if="item.hasChildren"
class="size-4 cursor-pointer transition"
:class="{ 'rotate-90': isExpanded }"
@click.stop="handleToggle"
/>
<div v-else class="h-4 w-4">
<!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> -->
</div>
<Checkbox
v-if="multiple"
:checked="isSelected"
:indeterminate="isIndeterminate"
@click.stop="handleSelect"
/>
<div
class="flex items-center gap-1 pl-2"
@click="
($event) => {
$event.stopPropagation();
$event.preventDefault();
handleSelect();
}
"
>
<slot name="node" v-bind="item">
<IconifyIcon
class="size-4"
v-if="showIcon && get(item.value, iconField)"
:icon="get(item.value, iconField)"
/>
{{ get(item.value, labelField) }}
</slot>
</div>
</TreeItem>
<div class="w-full" v-if="$slots.footer">
<slot name="footer"> </slot>
</div>
</TreeRoot>
</template>