Files
workspace/code/fms/fms-vue/src/components/fms-tree/FmsTree.vue
T
2026-09-15 22:10:13 +08:00

216 lines
8.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>