216 lines
8.8 KiB
Vue
216 lines
8.8 KiB
Vue
<script setup>
|
||
/**
|
||
* FmsTree —— FMS 业务树(封装组件库 ui/Tree)。
|
||
*
|
||
* 渲染、交互、拖拽、右键菜单、键盘导航、无障碍全部由 ui/Tree 承担;本组件只做 FMS 业务约定适配:
|
||
* 1. 默认字段名 b_id / b_name / children,调用方不必重复书写 fieldNames;
|
||
* 2. 节点标题按 b_i18n 翻译(translate(b_i18n, b_name || b_code || b_id)),关闭 translateTitle 则直接取原值;
|
||
* 3. 可选「全部分类」虚拟根节点(showRoot),默认展开、可折叠;调用方只以「选中键为空」
|
||
* 表达「选中了根」,不需要知道 rootKey,详见下方「虚拟根的选中约定」;
|
||
* 4. 加载态(loading)由本组件统一渲染遮罩,调用方不必各自写 Spin 与定位样式。
|
||
*
|
||
* 除下表声明的 props 外,其余 props 与事件(draggable / filter / disabled / contextMenu /
|
||
* selectedKeys / checkedKeys / lazy / v-model:selected-keys …)经 $attrs 原样透传给 ui/Tree,
|
||
* 事件名与 ui/Tree 保持一致;其中 select 事件额外做了一层归一化,见下。
|
||
*
|
||
* 虚拟根的选中约定(showRoot 时):
|
||
* - 传入:selectedKeys 为 [] / 空值时,本组件自动把虚拟根当作选中项渲染(高亮「全部分类」);
|
||
* - 传出:用户点中虚拟根时,select 事件回传的 keys 是 [](不是 [rootKey])。
|
||
* 于是调用方只需用「falsy 的选中键」表达「全部」这一个概念,无需 import rootKey、也无需再判 isFmsRoot。
|
||
*
|
||
* 注意:本组件始终向 ui/Tree 提供 #title 插槽(默认渲染翻译后的标题),因此 ui/Tree 内置的
|
||
* filter 关键词高亮不再生效(过滤本身照常生效);需要高亮时在 #title 插槽内自行实现。
|
||
*
|
||
* 用法:
|
||
* <FmsTree
|
||
* :tree-data="moduleTree"
|
||
* :loading="categoryLoading"
|
||
* :expanded-keys="expandedKeys"
|
||
* :selected-keys="selectedId ? [selectedId] : []"
|
||
* :context-menu="buildContextMenu"
|
||
* draggable
|
||
* @select="onSelect"
|
||
* @context-menu-click="onMenuClick"
|
||
* >
|
||
* <template #title="{ node, title }">
|
||
* <span :class="{ 'is-disabled': Number(node.b_canuse) !== 1 }">{{ title }}</span>
|
||
* </template>
|
||
* </FmsTree>
|
||
*/
|
||
import { computed, ref, useAttrs } from 'vue'
|
||
import { Spin, Tree } from '@/components/ui'
|
||
import { translate } from '@/stores/i18n'
|
||
|
||
defineOptions({ inheritAttrs: false })
|
||
|
||
/** 未声明的 ui/Tree props(含 selectedKeys)经 $attrs 透传,归一化时需读取原值 */
|
||
const attrs = useAttrs()
|
||
|
||
/** 虚拟根节点 key:纯内部实现细节,调用方用「空选中键」表达选中根,不需要也不应引用它 */
|
||
const ALL_CATEGORY_KEY = '__fms_tree_root__'
|
||
|
||
const props = defineProps({
|
||
/** 业务树数据(b_id / b_name / children) */
|
||
treeData: { type: Array, default: () => [] },
|
||
/** 字段名覆盖:与 FMS 默认 { key: 'b_id', title: 'b_name', children: 'children' } 浅合并 */
|
||
fieldNames: { type: Object, default: () => ({}) },
|
||
/** 节点标题是否按 b_i18n 翻译 */
|
||
translateTitle: { type: Boolean, default: true },
|
||
/** 是否注入「全部分类」虚拟根节点 */
|
||
showRoot: { type: Boolean, default: false },
|
||
/** 虚拟根节点 key */
|
||
rootKey: { type: [String, Number], default: ALL_CATEGORY_KEY },
|
||
/** 虚拟根节点标题,空则取多语言键 common.allCategories */
|
||
rootTitle: { type: String, default: '' },
|
||
/** 加载态:树区域盖一层居中 Spin 遮罩(懒加载子节点的局部 loading 仍由 ui/Tree 负责) */
|
||
loading: { type: Boolean, default: false },
|
||
/**
|
||
* 展开键。undefined 表示非受控:无虚拟根时原样交给 ui/Tree(default-expand-all 等初始展开配置照常生效),
|
||
* 有虚拟根时由本组件用内部状态维护,并照常抛出 update:expandedKeys。
|
||
*/
|
||
expandedKeys: { type: Array, default: undefined },
|
||
})
|
||
|
||
const emit = defineEmits(['update:expandedKeys', 'select'])
|
||
|
||
/** showRoot 且调用方未受控时,本组件用内部状态持有展开键(始终不含 rootKey) */
|
||
const internalExpandedKeys = ref([])
|
||
|
||
const fn = computed(() => ({
|
||
key: 'b_id',
|
||
title: 'b_name',
|
||
children: 'children',
|
||
...props.fieldNames,
|
||
}))
|
||
|
||
/** 节点标题:b_i18n 有译文时取译文,否则按 b_name → b_code → b_id 回退 */
|
||
function resolveNodeTitle(node) {
|
||
if (node?.isFmsRoot) {
|
||
return props.rootTitle || translate('common.allCategories', '全部分类')
|
||
}
|
||
const fallback = node?.[fn.value.title] || node?.b_code || node?.[fn.value.key] || ''
|
||
return props.translateTitle ? translate(node?.b_i18n, fallback) : fallback
|
||
}
|
||
|
||
/**
|
||
* 实际渲染的树数据。
|
||
* 虚拟根只在外层包一层、不复制业务节点:节点仍是业务树里的同一个对象,
|
||
* 调用方对节点的改动(如启用勾选)能正常触发重渲染,按 b_id 反查父级/落点的逻辑也不会看到 rootKey。
|
||
* 根是分组节点、不是业务叶子:显式 isLeaf=false,业务树为空时也照样显示折叠箭头
|
||
* (否则空列表下根会被推断成叶子,界面上看不出这里还能展开)。
|
||
*/
|
||
const effectiveTreeData = computed(() => {
|
||
if (!props.showRoot) return props.treeData
|
||
return [
|
||
{
|
||
[fn.value.key]: props.rootKey,
|
||
isFmsRoot: true,
|
||
isLeaf: false,
|
||
// 虚拟根是整棵树的容器,不参与拖拽排序(仍可作为落点,把节点拖进根 = 移到顶层)
|
||
draggable: false,
|
||
[fn.value.children]: props.treeData,
|
||
},
|
||
]
|
||
})
|
||
|
||
/** 根被用户手动收起后置 true:不再补回 rootKey,使折叠箭头能正常工作 */
|
||
const rootCollapsed = ref(false)
|
||
|
||
const hasRootKey = (keys) => keys.some((key) => String(key) === String(props.rootKey))
|
||
|
||
/**
|
||
* 选中键归一化(仅 showRoot 时生效):
|
||
* 传入 [] / 空值 → 补成 [rootKey],让虚拟根呈现选中态,调用方用「空」表达「选中全部」;
|
||
* 传入非空数组 → 原样透传,业务节点的选中完全由调用方决定。
|
||
* 只补虚拟根这一个 key,不会顺手选中任何业务节点。
|
||
*
|
||
* 注意 selectedKeys 与其他 ui/Tree props 一样走 $attrs(本组件未声明它),
|
||
* 因此这里必须从 attrs 取值;若从 props 取会永远拿到 undefined,
|
||
* 从而把调用方传的真实选中项覆盖成虚拟根。
|
||
*/
|
||
const effectiveSelectedKeys = computed(() => {
|
||
const keys = attrs.selectedKeys
|
||
if (!props.showRoot) return keys
|
||
if (keys && keys.length) return keys
|
||
return [props.rootKey]
|
||
})
|
||
|
||
/**
|
||
* select 事件归一化:点中虚拟根时回传 [] 而非 [rootKey]。
|
||
* 与 effectiveSelectedKeys 是一对镜像处理,保证调用方永远看不到 rootKey。
|
||
*/
|
||
function onSelect(keys, info) {
|
||
const isRoot = info?.node?.isFmsRoot || hasRootKey(keys)
|
||
emit('select', isRoot ? [] : keys, info)
|
||
}
|
||
|
||
/**
|
||
* 虚拟根默认展开(调用方的展开键里不需要出现 rootKey);
|
||
* 用户收起根之后不再强制补回,避免箭头点不动。
|
||
*/
|
||
const effectiveExpandedKeys = computed(() => {
|
||
const keys = props.expandedKeys
|
||
const withRoot = (list) =>
|
||
props.showRoot && !rootCollapsed.value && !hasRootKey(list) ? [props.rootKey, ...list] : list
|
||
if (keys === undefined) {
|
||
return props.showRoot ? withRoot(internalExpandedKeys.value) : undefined
|
||
}
|
||
return withRoot(keys)
|
||
})
|
||
|
||
function onExpandedKeysChange(keys) {
|
||
// 用户收起/展开根:以本次的展开键是否含 rootKey 为准
|
||
if (props.showRoot) rootCollapsed.value = !hasRootKey(keys)
|
||
// 非受控时自己留一份(去掉根 key,根的展开由 effectiveExpandedKeys 统一补回)
|
||
if (props.expandedKeys === undefined && props.showRoot) {
|
||
internalExpandedKeys.value = keys.filter((key) => String(key) !== String(props.rootKey))
|
||
}
|
||
emit('update:expandedKeys', keys)
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="fms-tree">
|
||
<Tree
|
||
v-bind="$attrs"
|
||
:tree-data="effectiveTreeData"
|
||
:field-names="fn"
|
||
:expanded-keys="effectiveExpandedKeys"
|
||
:selected-keys="effectiveSelectedKeys"
|
||
@update:expanded-keys="onExpandedKeysChange"
|
||
@select="onSelect"
|
||
>
|
||
<!-- 标题:默认渲染翻译后的文本;调用方可用 #title 覆盖,并从插槽参数取翻译结果 -->
|
||
<template #title="scope">
|
||
<slot name="title" :node="scope.node" :title="resolveNodeTitle(scope.node)">
|
||
{{ resolveNodeTitle(scope.node) }}
|
||
</slot>
|
||
</template>
|
||
<template v-if="$slots.empty" #empty>
|
||
<slot name="empty" />
|
||
</template>
|
||
</Tree>
|
||
<!-- 加载遮罩盖住树区域;#loading 插槽可覆盖默认的居中 Spin -->
|
||
<div v-if="loading" class="fms-tree__loading">
|
||
<slot name="loading">
|
||
<Spin />
|
||
</slot>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
.fms-tree {
|
||
position: relative;
|
||
min-width: 0;
|
||
}
|
||
|
||
.fms-tree__loading {
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
background: rgb(255 255 255 / 60%);
|
||
}
|
||
</style>
|