feat(tree): add renderIcon props close #309
| | |
| | | - 新增修改密码界面 |
| | | - 新增部门管理示例界面 |
| | | - 新增 WebSocket 示例和服务脚本 |
| | | - BasicTree 组件新增 `renderIcon` 属性用于控制层级图标显示 |
| | | |
| | | ### ⚡ Performance Improvements |
| | | |
| | |
| | | import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent'; |
| | | import BasicTree from './src/index.vue'; |
| | | |
| | | export const BasicTree = createAsyncComponent(() => import('./src/BasicTree')); |
| | | |
| | | export { BasicTree }; |
| | | export type { ContextMenuItem } from '/@/hooks/web/useContextMenu'; |
| | | export * from './src/types'; |
New file |
| | |
| | | import type { VNode, FunctionalComponent } from 'vue'; |
| | | |
| | | import { h } from 'vue'; |
| | | import { isString } from '/@/utils/is'; |
| | | import { Icon } from '/@/components/Icon'; |
| | | |
| | | export interface ComponentProps { |
| | | icon: VNode | string; |
| | | } |
| | | |
| | | export const TreeIcon: FunctionalComponent = ({ icon }: ComponentProps) => { |
| | | if (!icon) return null; |
| | | if (isString(icon)) { |
| | | return h(Icon, { icon, class: 'mr-1' }); |
| | | } |
| | | return Icon; |
| | | }; |
New file |
| | |
| | | <script lang="tsx"> |
| | | import type { ReplaceFields, Keys, CheckKeys, TreeActionType, TreeItem } from './types'; |
| | | |
| | | import { defineComponent, reactive, computed, unref, ref, watchEffect, onMounted } from 'vue'; |
| | | import { Tree } from 'ant-design-vue'; |
| | | import { TreeIcon } from './TreeIcon'; |
| | | // import { DownOutlined } from '@ant-design/icons-vue'; |
| | | |
| | | import { omit, get } from 'lodash-es'; |
| | | import { isFunction } from '/@/utils/is'; |
| | | import { extendSlots } from '/@/utils/helper/tsxHelper'; |
| | | |
| | | import { useTree } from './useTree'; |
| | | import { useContextMenu, ContextMenuItem } from '/@/hooks/web/useContextMenu'; |
| | | import { useExpose } from '/@/hooks/core/useExpose'; |
| | | import { useDesign } from '/@/hooks/web/useDesign'; |
| | | |
| | | import { basicProps } from './props'; |
| | | |
| | | interface State { |
| | | expandedKeys: Keys; |
| | | selectedKeys: Keys; |
| | | checkedKeys: CheckKeys; |
| | | } |
| | | export default defineComponent({ |
| | | name: 'BasicTree', |
| | | props: basicProps, |
| | | emits: ['update:expandedKeys', 'update:selectedKeys', 'update:value', 'get'], |
| | | setup(props, { attrs, slots, emit }) { |
| | | const state = reactive<State>({ |
| | | expandedKeys: props.expandedKeys || [], |
| | | selectedKeys: props.selectedKeys || [], |
| | | checkedKeys: props.checkedKeys || [], |
| | | }); |
| | | |
| | | const treeDataRef = ref<TreeItem[]>([]); |
| | | |
| | | const [createContextMenu] = useContextMenu(); |
| | | const { prefixCls } = useDesign('basic-tree'); |
| | | |
| | | const getReplaceFields = computed( |
| | | (): Required<ReplaceFields> => { |
| | | const { replaceFields } = props; |
| | | return { |
| | | children: 'children', |
| | | title: 'title', |
| | | key: 'key', |
| | | ...replaceFields, |
| | | }; |
| | | } |
| | | ); |
| | | |
| | | // const getContentStyle = computed( |
| | | // (): CSSProperties => { |
| | | // const { actionList } = props; |
| | | // const width = actionList.length * 18; |
| | | // return { |
| | | // width: `calc(100% - ${width}px)`, |
| | | // }; |
| | | // } |
| | | // ); |
| | | |
| | | const getBindValues = computed(() => { |
| | | let propsData = { |
| | | blockNode: true, |
| | | ...attrs, |
| | | ...props, |
| | | expandedKeys: state.expandedKeys, |
| | | selectedKeys: state.selectedKeys, |
| | | checkedKeys: state.checkedKeys, |
| | | replaceFields: unref(getReplaceFields), |
| | | 'onUpdate:expandedKeys': (v: Keys) => { |
| | | state.expandedKeys = v; |
| | | emit('update:expandedKeys', v); |
| | | }, |
| | | 'onUpdate:selectedKeys': (v: Keys) => { |
| | | state.selectedKeys = v; |
| | | emit('update:selectedKeys', v); |
| | | }, |
| | | onCheck: (v: CheckKeys, e) => { |
| | | state.checkedKeys = v; |
| | | console.log(e); |
| | | emit('update:value', v); |
| | | }, |
| | | onRightClick: handleRightClick, |
| | | }; |
| | | propsData = omit(propsData, 'treeData'); |
| | | return propsData; |
| | | }); |
| | | |
| | | const getTreeData = computed((): TreeItem[] => unref(treeDataRef)); |
| | | |
| | | const { deleteNodeByKey, insertNodeByKey, filterByLevel, updateNodeByKey } = useTree( |
| | | treeDataRef, |
| | | getReplaceFields |
| | | ); |
| | | |
| | | function getIcon(params: Recordable, icon?: string) { |
| | | if (!icon) { |
| | | if (props.renderIcon && isFunction(props.renderIcon)) { |
| | | return props.renderIcon(params); |
| | | } |
| | | } |
| | | return icon; |
| | | } |
| | | |
| | | function renderAction(node: TreeItem) { |
| | | const { actionList } = props; |
| | | if (!actionList || actionList.length === 0) return; |
| | | return actionList.map((item, index) => { |
| | | return ( |
| | | <span key={index} class={`${prefixCls}__action`}> |
| | | {item.render(node)} |
| | | </span> |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | function renderTreeNode({ data, level }: { data: TreeItem[] | undefined; level: number }) { |
| | | if (!data) { |
| | | return null; |
| | | } |
| | | return data.map((item) => { |
| | | const { title: titleField, key: keyField, children: childrenField } = unref( |
| | | getReplaceFields |
| | | ); |
| | | |
| | | const propsData = omit(item, 'title'); |
| | | const icon = getIcon({ ...item, level }, item.icon); |
| | | return ( |
| | | <Tree.TreeNode {...propsData} key={get(item, keyField)}> |
| | | {{ |
| | | title: () => ( |
| | | <span class={`${prefixCls}-title`}> |
| | | {icon && <TreeIcon icon={icon} />} |
| | | <span |
| | | class={`${prefixCls}__content`} |
| | | // style={unref(getContentStyle)} |
| | | > |
| | | {get(item, titleField)} |
| | | </span> |
| | | <span class={`${prefixCls}__actions`}> {renderAction(item)}</span> |
| | | </span> |
| | | ), |
| | | default: () => |
| | | renderTreeNode({ data: get(item, childrenField) || [], level: level + 1 }), |
| | | }} |
| | | </Tree.TreeNode> |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | async function handleRightClick({ event, node }: any) { |
| | | const { rightMenuList: menuList = [], beforeRightClick } = props; |
| | | let rightMenuList: ContextMenuItem[] = []; |
| | | |
| | | if (beforeRightClick && isFunction(beforeRightClick)) { |
| | | rightMenuList = await beforeRightClick(node); |
| | | } else { |
| | | rightMenuList = menuList; |
| | | } |
| | | if (!rightMenuList.length) return; |
| | | createContextMenu({ |
| | | event, |
| | | items: rightMenuList, |
| | | }); |
| | | } |
| | | |
| | | function setExpandedKeys(keys: string[]) { |
| | | state.expandedKeys = keys; |
| | | } |
| | | |
| | | function getExpandedKeys() { |
| | | return state.expandedKeys; |
| | | } |
| | | function setSelectedKeys(keys: string[]) { |
| | | state.selectedKeys = keys; |
| | | } |
| | | |
| | | function getSelectedKeys() { |
| | | return state.selectedKeys; |
| | | } |
| | | |
| | | function setCheckedKeys(keys: CheckKeys) { |
| | | state.checkedKeys = keys; |
| | | } |
| | | |
| | | function getCheckedKeys() { |
| | | return state.checkedKeys; |
| | | } |
| | | |
| | | watchEffect(() => { |
| | | treeDataRef.value = props.treeData as TreeItem[]; |
| | | state.expandedKeys = props.expandedKeys; |
| | | state.selectedKeys = props.selectedKeys; |
| | | state.checkedKeys = props.checkedKeys; |
| | | }); |
| | | |
| | | const instance: TreeActionType = { |
| | | setExpandedKeys, |
| | | getExpandedKeys, |
| | | setSelectedKeys, |
| | | getSelectedKeys, |
| | | setCheckedKeys, |
| | | getCheckedKeys, |
| | | insertNodeByKey, |
| | | deleteNodeByKey, |
| | | updateNodeByKey, |
| | | filterByLevel: (level: number) => { |
| | | state.expandedKeys = filterByLevel(level); |
| | | }, |
| | | }; |
| | | |
| | | useExpose<TreeActionType>(instance); |
| | | |
| | | onMounted(() => { |
| | | emit('get', instance); |
| | | }); |
| | | |
| | | return () => { |
| | | return ( |
| | | <Tree {...unref(getBindValues)} showIcon={false} class={[prefixCls]}> |
| | | {{ |
| | | // switcherIcon: () => <DownOutlined />, |
| | | default: () => renderTreeNode({ data: unref(getTreeData), level: 1 }), |
| | | ...extendSlots(slots), |
| | | }} |
| | | </Tree> |
| | | ); |
| | | }; |
| | | }, |
| | | }); |
| | | </script> |
| | | <style lang="less"> |
| | | @prefix-cls: ~'@{namespace}-basic-tree'; |
| | | |
| | | .@{prefix-cls} { |
| | | position: relative; |
| | | |
| | | .ant-tree-node-content-wrapper { |
| | | position: relative; |
| | | |
| | | .ant-tree-title { |
| | | position: absolute; |
| | | left: 0; |
| | | width: 100%; |
| | | } |
| | | } |
| | | |
| | | &-title { |
| | | position: relative; |
| | | display: flex; |
| | | align-items: center; |
| | | width: 100%; |
| | | padding-right: 10px; |
| | | |
| | | &:hover { |
| | | .@{prefix-cls}__action { |
| | | visibility: visible; |
| | | } |
| | | } |
| | | } |
| | | |
| | | &__content { |
| | | display: inline-block; |
| | | overflow: hidden; |
| | | } |
| | | |
| | | &__actions { |
| | | position: absolute; |
| | | top: 2px; |
| | | right: 2px; |
| | | display: flex; |
| | | } |
| | | |
| | | &__action { |
| | | margin-left: 4px; |
| | | visibility: hidden; |
| | | } |
| | | } |
| | | </style> |
| | |
| | | import { PropType } from 'vue'; |
| | | import type { ReplaceFields, TreeItem, ActionItem, Keys, CheckKeys } from './types'; |
| | | import type { PropType } from 'vue'; |
| | | import type { ReplaceFields, ActionItem, Keys, CheckKeys } from './types'; |
| | | import type { ContextMenuItem } from '/@/hooks/web/useContextMenu'; |
| | | import type { TreeDataItem } from 'ant-design-vue/es/tree/Tree'; |
| | | |
| | | export const basicProps = { |
| | | renderIcon: { |
| | | type: Function as PropType<(params: Recordable) => string>, |
| | | }, |
| | | replaceFields: { |
| | | type: Object as PropType<ReplaceFields>, |
| | | }, |
| | | |
| | | treeData: { |
| | | type: Array as PropType<TreeItem[]>, |
| | | type: Array as PropType<TreeDataItem[]>, |
| | | }, |
| | | |
| | | actionList: { |
| | |
| | | type: Object as PropType<ReplaceFields>, |
| | | }, |
| | | treeData: { |
| | | type: Array as PropType<TreeItem[]>, |
| | | type: Array as PropType<TreeDataItem[]>, |
| | | default: () => [], |
| | | }, |
| | | }; |
| | |
| | | import type { TreeDataItem } from 'ant-design-vue/es/tree/Tree'; |
| | | export interface ActionItem { |
| | | render: (record: any) => any; |
| | | } |
| | | |
| | | export interface TreeItem { |
| | | /** |
| | | * Class |
| | | * @description className |
| | | * @type string |
| | | */ |
| | | class?: string; |
| | | |
| | | /** |
| | | * Style |
| | | * @description style of tree node |
| | | * @type string | object |
| | | */ |
| | | style?: string | object; |
| | | |
| | | /** |
| | | * Disable Checkbox |
| | | * @description Disables the checkbox of the treeNode |
| | | * @default false |
| | | * @type boolean |
| | | */ |
| | | disableCheckbox?: boolean; |
| | | |
| | | /** |
| | | * Disabled |
| | | * @description Disabled or not |
| | | * @default false |
| | | * @type boolean |
| | | */ |
| | | disabled?: boolean; |
| | | |
| | | /** |
| | | * Icon |
| | | * @description customize icon. When you pass component, whose render will receive full TreeNode props as component props |
| | | * @type any (slot | slot-scope) |
| | | */ |
| | | export interface TreeItem extends TreeDataItem { |
| | | icon?: any; |
| | | |
| | | /** |
| | | * Is Leaf? |
| | | * @description Leaf node or not |
| | | * @default false |
| | | * @type boolean |
| | | */ |
| | | isLeaf?: boolean; |
| | | |
| | | /** |
| | | * Key |
| | | * @description Required property, should be unique in the tree |
| | | * (In tree: Used with (default)ExpandedKeys / (default)CheckedKeys / (default)SelectedKeys) |
| | | * @default internal calculated position of treeNode or undefined |
| | | * @type string | number |
| | | */ |
| | | key: string | number; |
| | | |
| | | /** |
| | | * Selectable |
| | | * @description Set whether the treeNode can be selected |
| | | * @default true |
| | | * @type boolean |
| | | */ |
| | | selectable?: boolean; |
| | | |
| | | /** |
| | | * Title |
| | | * @description Content showed on the treeNodes |
| | | * @default '---' |
| | | * @type any (string | slot) |
| | | */ |
| | | title: any; |
| | | |
| | | /** |
| | | * Value |
| | | * @description Will be treated as treeNodeFilterProp by default, should be unique in the tree |
| | | * @default undefined |
| | | * @type string |
| | | */ |
| | | value?: string; |
| | | children?: TreeItem[]; |
| | | slots?: any; |
| | | scopedSlots?: any; |
| | | } |
| | | |
| | | export interface ReplaceFields { |
| | |
| | | filterByLevel: (level: number) => void; |
| | | insertNodeByKey: (opt: InsertNodeParams) => void; |
| | | deleteNodeByKey: (key: string) => void; |
| | | updateNodeByKey: (key: string, node: Omit<TreeItem, 'key'>) => void; |
| | | updateNodeByKey: (key: string, node: Omit<TreeDataItem, 'key'>) => void; |
| | | } |
| | | |
| | | export interface InsertNodeParams { |
| | | parentKey: string | null; |
| | | node: TreeItem; |
| | | list?: TreeItem[]; |
| | | node: TreeDataItem; |
| | | list?: TreeDataItem[]; |
| | | push?: 'push' | 'unshift'; |
| | | } |
| | |
| | | import type { InsertNodeParams, ReplaceFields, TreeItem } from './types'; |
| | | import type { InsertNodeParams, ReplaceFields } from './types'; |
| | | import type { Ref, ComputedRef } from 'vue'; |
| | | import type { TreeDataItem } from 'ant-design-vue/es/tree/Tree'; |
| | | |
| | | import { cloneDeep } from 'lodash-es'; |
| | | import { unref } from 'vue'; |
| | | import { forEach } from '/@/utils/helper/treeHelper'; |
| | | |
| | | export function useTree( |
| | | treeDataRef: Ref<TreeItem[]>, |
| | | treeDataRef: Ref<TreeDataItem[]>, |
| | | getReplaceFields: ComputedRef<ReplaceFields> |
| | | ) { |
| | | // 更新节点 |
| | | function updateNodeByKey(key: string, node: TreeItem, list?: TreeItem[]) { |
| | | // Update node |
| | | function updateNodeByKey(key: string, node: TreeDataItem, list?: TreeDataItem[]) { |
| | | if (!key) return; |
| | | const treeData = list || unref(treeDataRef); |
| | | const { key: keyField, children: childrenField } = unref(getReplaceFields); |
| | |
| | | } |
| | | } |
| | | |
| | | // 展开指定级别 |
| | | function filterByLevel(level = 1, list?: TreeItem[], currentLevel = 1) { |
| | | // Expand the specified level |
| | | function filterByLevel(level = 1, list?: TreeDataItem[], currentLevel = 1) { |
| | | if (!level) { |
| | | return []; |
| | | } |
| | |
| | | treeDataRef.value = treeData; |
| | | } |
| | | |
| | | // 删除节点 |
| | | function deleteNodeByKey(key: string, list?: TreeItem[]) { |
| | | // Delete node |
| | | function deleteNodeByKey(key: string, list?: TreeDataItem[]) { |
| | | if (!key) return; |
| | | const treeData = list || unref(treeDataRef); |
| | | const { key: keyField, children: childrenField } = unref(getReplaceFields); |
| | |
| | | setup() { |
| | | const treeRef = ref<Nullable<TreeActionType>>(null); |
| | | const { createMessage } = useMessage(); |
| | | |
| | | function getTree() { |
| | | const tree = unref(treeRef); |
| | | if (!tree) { |
| | |
| | | <template> |
| | | <PageWrapper title="Tree函数操作示例"> |
| | | <div class="flex"> |
| | | <CollapseContainer title="右侧操作按钮" class="mr-4" :style="{ width: '33%' }"> |
| | | <BasicTree :treeData="treeData" :actionList="actionList" /> |
| | | <CollapseContainer title="右侧操作按钮/自定义图标" class="mr-4" :style="{ width: '33%' }"> |
| | | <BasicTree :treeData="treeData" :actionList="actionList" :renderIcon="createIcon" /> |
| | | </CollapseContainer> |
| | | |
| | | <CollapseContainer title="右键菜单" class="mr-4" :style="{ width: '33%' }"> |
| | |
| | | }, |
| | | }, |
| | | ]; |
| | | return { treeData, actionList, getRightMenuList }; |
| | | |
| | | function createIcon({ level }) { |
| | | if (level === 1) { |
| | | return 'ion:git-compare-outline'; |
| | | } |
| | | if (level === 2) { |
| | | return 'ion:home'; |
| | | } |
| | | if (level === 3) { |
| | | return 'ion:airplane'; |
| | | } |
| | | } |
| | | return { treeData, actionList, getRightMenuList, createIcon }; |
| | | }, |
| | | }); |
| | | </script> |
| | |
| | | |
| | | export const treeData: TreeItem[] = [ |
| | | { |
| | | title: 'parent 1parent ', |
| | | title: 'parent ', |
| | | key: '0-0', |
| | | icon: 'home|svg', |
| | | children: [ |
| | | { title: 'leaf', key: '0-0-0' }, |
| | | { |
| | |
| | | { |
| | | title: 'parent 2', |
| | | key: '1-1', |
| | | icon: 'home|svg', |
| | | children: [ |
| | | { title: 'leaf', key: '1-1-0' }, |
| | | { title: 'leaf', key: '1-1-1' }, |
| | |
| | | { |
| | | title: 'parent 3', |
| | | key: '2-2', |
| | | icon: 'home|svg', |
| | | children: [ |
| | | { title: 'leaf', key: '2-2-0' }, |
| | | { title: 'leaf', key: '2-2-1' }, |