This commit is contained in:
dap 2025-03-27 19:27:26 +08:00
commit c0476613d7
6 changed files with 278 additions and 212 deletions

View File

@ -44,6 +44,7 @@ import { FileUpload, ImageUpload } from '#/components/upload';
const withDefaultPlaceholder = <T extends Component>( const withDefaultPlaceholder = <T extends Component>(
component: T, component: T,
type: 'input' | 'select', type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => { ) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
@ -74,7 +75,13 @@ const withDefaultPlaceholder = <T extends Component>(
return () => return () =>
h( h(
component, component,
{ ...props, ...attrs, placeholder: placeholder.value, ref: innerRef }, {
...componentProps,
placeholder: placeholder.value,
...props,
...attrs,
ref: innerRef,
},
slots, slots,
); );
}, },
@ -118,38 +125,20 @@ async function initComponentAdapter() {
// 如果你的组件体积比较大,可以使用异步加载 // 如果你的组件体积比较大,可以使用异步加载
// Button: () => // Button: () =>
// import('xxx').then((res) => res.Button), // import('xxx').then((res) => res.Button),
ApiSelect: (props, { attrs, slots }) => { ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
return h( component: Select,
ApiComponent, loadingSlot: 'suffixIcon',
{ visibleEvent: 'onDropdownVisibleChange',
placeholder: $t('ui.placeholder.select'), modelPropName: 'value',
...props, }),
...attrs, ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: Select, component: TreeSelect,
loadingSlot: 'suffixIcon', fieldNames: { label: 'label', value: 'value', children: 'children' },
visibleEvent: 'onDropdownVisibleChange', loadingSlot: 'suffixIcon',
modelPropName: 'value', modelPropName: 'value',
}, optionsPropName: 'treeData',
slots, visibleEvent: 'onVisibleChange',
); }),
},
ApiTreeSelect: (props, { attrs, slots }) => {
return h(
ApiComponent,
{
placeholder: $t('ui.placeholder.select'),
...props,
...attrs,
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
slots,
);
},
AutoComplete, AutoComplete,
Checkbox, Checkbox,
CheckboxGroup, CheckboxGroup,
@ -159,19 +148,11 @@ async function initComponentAdapter() {
return h(Button, { ...props, attrs, type: 'default' }, slots); return h(Button, { ...props, attrs, type: 'default' }, slots);
}, },
Divider, Divider,
IconPicker: (props, { attrs, slots }) => { IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
return h( iconSlot: 'addonAfter',
IconPicker, inputComponent: Input,
{ modelValueProp: 'value',
iconSlot: 'addonAfter', }),
inputComponent: Input,
modelValueProp: 'value',
...props,
...attrs,
},
slots,
);
},
Input: withDefaultPlaceholder(Input, 'input'), Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'), InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'), InputPassword: withDefaultPlaceholder(InputPassword, 'input'),

View File

@ -4,7 +4,9 @@ import type { FlattenedItem } from 'radix-vue';
import type { ClassType, Recordable } from '@vben-core/typings'; import type { ClassType, Recordable } from '@vben-core/typings';
import { onMounted, ref, watch, watchEffect } from 'vue'; import type { TreeProps } from './types';
import { onMounted, ref, watchEffect } from 'vue';
import { ChevronRight, IconifyIcon } from '@vben-core/icons'; import { ChevronRight, IconifyIcon } from '@vben-core/icons';
import { cn, get } from '@vben-core/shared/utils'; import { cn, get } from '@vben-core/shared/utils';
@ -14,46 +16,13 @@ import { TreeItem, TreeRoot } from 'radix-vue';
import { Checkbox } from '../checkbox'; 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>(), { const props = withDefaults(defineProps<TreeProps>(), {
allowClear: false, allowClear: false,
autoCheckParent: true,
bordered: false, bordered: false,
checkStrictly: false, checkStrictly: false,
defaultExpandedKeys: () => [], defaultExpandedKeys: () => [],
defaultExpandedLevel: 0,
disabled: false, disabled: false,
expanded: () => [], expanded: () => [],
iconField: 'icon', iconField: 'icon',
@ -61,7 +30,7 @@ const props = withDefaults(defineProps<TreeProps>(), {
modelValue: () => [], modelValue: () => [],
multiple: false, multiple: false,
showIcon: true, showIcon: true,
transition: false, transition: true,
valueField: 'value', valueField: 'value',
childrenField: 'children', childrenField: 'children',
}); });
@ -72,28 +41,36 @@ const emits = defineEmits<{
'update:modelValue': [value: Arrayable<Recordable<any>>]; 'update:modelValue': [value: Arrayable<Recordable<any>>];
}>(); }>();
interface InnerFlattenItem<T = Recordable<any>> { interface InnerFlattenItem<T = Recordable<any>, P = number | string> {
hasChildren: boolean; hasChildren: boolean;
level: number; level: number;
parents: P[];
value: T; value: T;
} }
function flatten<T = Recordable<any>>( function flatten<T = Recordable<any>, P = number | string>(
items: T[], items: T[],
childrenField: string = 'children', childrenField: string = 'children',
level = 0, level = 0,
): InnerFlattenItem<T>[] { parents: P[] = [],
const result: InnerFlattenItem<T>[] = []; ): InnerFlattenItem<T, P>[] {
const result: InnerFlattenItem<T, P>[] = [];
items.forEach((item) => { items.forEach((item) => {
const children = get(item, childrenField) as Array<T>; const children = get(item, childrenField) as Array<T>;
const val = { const val = {
hasChildren: Array.isArray(children) && children.length > 0, hasChildren: Array.isArray(children) && children.length > 0,
level, level,
parents: [...parents],
value: item, value: item,
}; };
result.push(val); result.push(val);
if (val.hasChildren) if (val.hasChildren)
result.push(...flatten(children, childrenField, level + 1)); result.push(
...flatten(children, childrenField, level + 1, [
...parents,
get(item, props.valueField),
]),
);
}); });
return result; return result;
} }
@ -133,14 +110,6 @@ function updateTreeValue() {
: getItemByValue(val); : getItemByValue(val);
} }
watch(
modelValue,
() => {
updateTreeValue();
},
{ deep: true, immediate: true },
);
function updateModelValue(val: Arrayable<Recordable<any>>) { function updateModelValue(val: Arrayable<Recordable<any>>) {
modelValue.value = Array.isArray(val) modelValue.value = Array.isArray(val)
? val.map((v) => get(v, props.valueField)) ? val.map((v) => get(v, props.valueField))
@ -186,7 +155,33 @@ function collapseAll() {
function onToggle(item: FlattenedItem<Recordable<any>>) { function onToggle(item: FlattenedItem<Recordable<any>>) {
emits('expand', item); emits('expand', item);
} }
function onSelect(item: FlattenedItem<Recordable<any>>) { function onSelect(item: FlattenedItem<Recordable<any>>, isSelected: boolean) {
if (
!props.checkStrictly &&
props.multiple &&
props.autoCheckParent &&
isSelected
) {
flattenData.value
.find((i) => {
return (
get(i.value, props.valueField) === get(item.value, props.valueField)
);
})
?.parents?.forEach((p) => {
if (Array.isArray(modelValue.value) && !modelValue.value.includes(p)) {
modelValue.value.push(p);
}
});
} else {
if (Array.isArray(modelValue.value)) {
const index = modelValue.value.indexOf(get(item.value, props.valueField));
if (index !== -1) {
modelValue.value.splice(index, 1);
}
}
}
updateTreeValue();
emits('select', item); emits('select', item);
} }
@ -224,78 +219,130 @@ defineExpose({
<div class="w-full" v-if="$slots.header"> <div class="w-full" v-if="$slots.header">
<slot name="header"> </slot> <slot name="header"> </slot>
</div> </div>
<TreeItem <TransitionGroup
v-for="item in flattenItems" :name="transition ? 'fade' : ''"
v-slot="{ mode="out-in"
isExpanded, class="container"
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 <TreeItem
v-if="item.hasChildren" v-for="item in flattenItems"
class="size-4 cursor-pointer transition" v-slot="{
:class="{ 'rotate-90': isExpanded }" isExpanded,
@click.stop="handleToggle" isSelected,
/> isIndeterminate,
<div v-else class="h-4 w-4"> handleSelect,
<!-- <IconifyIcon v-if="item.value.icon" :icon="item.value.icon" /> --> handleToggle,
</div> }"
<Checkbox :key="item._id"
v-if="multiple" :style="{ 'padding-left': `${item.level - 0.5}rem` }"
:checked="isSelected" :class="
:indeterminate="isIndeterminate" cn('cursor-pointer', getNodeClass?.(item), {
@click.stop="handleSelect" 'data-[selected]:bg-accent': !multiple,
/> })
<div "
class="flex items-center gap-1 pl-2" v-bind="item.bind"
@click=" @select="
($event) => { (event) => {
$event.stopPropagation(); if (event.detail.originalEvent.type === 'click') {
$event.preventDefault(); // event.preventDefault();
handleSelect(); }
onSelect(item, event.detail.isSelected);
} }
" "
@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"
> >
<slot name="node" v-bind="item"> <ChevronRight
<IconifyIcon v-if="item.hasChildren"
class="size-4" class="size-4 cursor-pointer transition"
v-if="showIcon && get(item.value, iconField)" :class="{ 'rotate-90': isExpanded }"
:icon="get(item.value, iconField)" @click.stop="
/> () => {
{{ get(item.value, labelField) }} handleToggle();
</slot> onToggle(item);
</div> }
</TreeItem> "
/>
<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="
() => {
handleSelect();
// onSelect(item, !isSelected);
}
"
/>
<div
class="flex items-center gap-1 pl-2"
@click="
(_event) => {
// $event.stopPropagation();
// $event.preventDefault();
handleSelect();
// onSelect(item, !isSelected);
}
"
>
<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>
</TransitionGroup>
<div class="w-full" v-if="$slots.footer"> <div class="w-full" v-if="$slots.footer">
<slot name="footer"> </slot> <slot name="footer"> </slot>
</div> </div>
</TreeRoot> </TreeRoot>
</template> </template>
<style lang="scss" scoped>
.container {
position: relative;
padding: 0;
list-style-type: none;
}
.item {
box-sizing: border-box;
width: 100%;
height: 30px;
background-color: #f3f3f3;
border: 1px solid #666;
}
/* 1. 声明过渡效果 */
.fade-move,
.fade-enter-active,
.fade-leave-active {
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
}
/* 2. 声明进入和离开的状态 */
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: scaleY(0.01) translate(30px, 0);
}
/* 3.
以便正确地计算移动时的动画效果 */
.fade-leave-active {
position: absolute;
}
</style>

View File

@ -0,0 +1,42 @@
import type { Arrayable } from '@vueuse/core';
import type { FlattenedItem } from 'radix-vue';
import type { Recordable } from '@vben-core/typings';
export interface TreeProps {
/** 单选时允许取消已有选项 */
allowClear?: boolean;
/** 非关联选择时,自动选中上级节点 */
autoCheckParent?: 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;
}

View File

@ -84,7 +84,7 @@ const emit = defineEmits<{
const modelValue = defineModel({ default: '' }); const modelValue = defineModel({ default: '' });
const attrs = useAttrs(); const attrs = useAttrs();
const innerParams = ref({});
const refOptions = ref<OptionsItem[]>([]); const refOptions = ref<OptionsItem[]>([]);
const loading = ref(false); const loading = ref(false);
// //
@ -175,8 +175,15 @@ async function handleFetchForVisible(visible: boolean) {
} }
} }
const params = computed(() => {
return {
...props.params,
...unref(innerParams),
};
});
watch( watch(
() => props.params, params,
(value, oldValue) => { (value, oldValue) => {
if (isEqual(value, oldValue)) { if (isEqual(value, oldValue)) {
return; return;
@ -189,12 +196,22 @@ watch(
function emitChange() { function emitChange() {
emit('optionsChange', unref(getOptions)); emit('optionsChange', unref(getOptions));
} }
const componentRef = ref();
defineExpose({
/** 获取被包装的组件实例 */
getComponentRef: <T = any,>() => componentRef.value as T,
/** 更新Api参数 */
updateParam(newParams: Record<string, any>) {
innerParams.value = newParams;
},
});
</script> </script>
<template> <template>
<component <component
:is="component" :is="component"
v-bind="bindProps" v-bind="bindProps"
:placeholder="$attrs.placeholder" :placeholder="$attrs.placeholder"
ref="componentRef"
> >
<template v-for="item in Object.keys($slots)" #[item]="data"> <template v-for="item in Object.keys($slots)" #[item]="data">
<slot :name="item" v-bind="data || {}"></slot> <slot :name="item" v-bind="data || {}"></slot>

View File

@ -41,6 +41,7 @@ import {
const withDefaultPlaceholder = <T extends Component>( const withDefaultPlaceholder = <T extends Component>(
component: T, component: T,
type: 'input' | 'select', type: 'input' | 'select',
componentProps: Recordable<any> = {},
) => { ) => {
return defineComponent({ return defineComponent({
inheritAttrs: false, inheritAttrs: false,
@ -63,7 +64,11 @@ const withDefaultPlaceholder = <T extends Component>(
} }
}); });
return () => return () =>
h(component, { ...props, ...attrs, placeholder, ref: innerRef }, slots); h(
component,
{ ...componentProps, placeholder, ...props, ...attrs, ref: innerRef },
slots,
);
}, },
}); });
}; };
@ -103,38 +108,20 @@ async function initComponentAdapter() {
// Button: () => // Button: () =>
// import('xxx').then((res) => res.Button), // import('xxx').then((res) => res.Button),
ApiSelect: (props, { attrs, slots }) => { ApiSelect: withDefaultPlaceholder(ApiComponent, 'select', {
return h( component: Select,
ApiComponent, loadingSlot: 'suffixIcon',
{ modelPropName: 'value',
placeholder: $t('ui.placeholder.select'), visibleEvent: 'onVisibleChange',
...props, }),
...attrs, ApiTreeSelect: withDefaultPlaceholder(ApiComponent, 'select', {
component: Select, component: TreeSelect,
loadingSlot: 'suffixIcon', fieldNames: { label: 'label', value: 'value', children: 'children' },
modelPropName: 'value', loadingSlot: 'suffixIcon',
visibleEvent: 'onVisibleChange', modelPropName: 'value',
}, optionsPropName: 'treeData',
slots, visibleEvent: 'onVisibleChange',
); }),
},
ApiTreeSelect: (props, { attrs, slots }) => {
return h(
ApiComponent,
{
placeholder: $t('ui.placeholder.select'),
...props,
...attrs,
component: TreeSelect,
fieldNames: { label: 'label', value: 'value', children: 'children' },
loadingSlot: 'suffixIcon',
modelPropName: 'value',
optionsPropName: 'treeData',
visibleEvent: 'onVisibleChange',
},
slots,
);
},
AutoComplete, AutoComplete,
Checkbox, Checkbox,
CheckboxGroup, CheckboxGroup,
@ -144,19 +131,11 @@ async function initComponentAdapter() {
return h(Button, { ...props, attrs, type: 'default' }, slots); return h(Button, { ...props, attrs, type: 'default' }, slots);
}, },
Divider, Divider,
IconPicker: (props, { attrs, slots }) => { IconPicker: withDefaultPlaceholder(IconPicker, 'select', {
return h( iconSlot: 'addonAfter',
IconPicker, inputComponent: Input,
{ modelValueProp: 'value',
iconSlot: 'addonAfter', }),
inputComponent: Input,
modelValueProp: 'value',
...props,
...attrs,
},
slots,
);
},
Input: withDefaultPlaceholder(Input, 'input'), Input: withDefaultPlaceholder(Input, 'input'),
InputNumber: withDefaultPlaceholder(InputNumber, 'input'), InputNumber: withDefaultPlaceholder(InputNumber, 'input'),
InputPassword: withDefaultPlaceholder(InputPassword, 'input'), InputPassword: withDefaultPlaceholder(InputPassword, 'input'),

View File

@ -98,7 +98,7 @@ function getNodeClass(node: Recordable<any>) {
<Drawer :title="getDrawerTitle"> <Drawer :title="getDrawerTitle">
<Form> <Form>
<template #permissions="slotProps"> <template #permissions="slotProps">
<Spin :spinning="loadingPermissions"> <Spin :spinning="loadingPermissions" wrapper-class-name="w-full">
<VbenTree <VbenTree
:tree-data="permissions" :tree-data="permissions"
multiple multiple