180 lines
6.3 KiB
Vue
180 lines
6.3 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 绑定模块面板(menu-management 右侧,与「菜单配置」并排)。
|
||
*
|
||
* 为什么是常驻面板而不是弹层:绑定是菜单属性的一部分,配完要能一眼看到「这个菜单挂了哪些模块」;
|
||
* 弹层则每次都要「打开 → 勾 → 确定」,确认前看不到已绑关系,也容易漏点确定白配一次。
|
||
*
|
||
* 交互:按模块树勾选当前页面使用的模块。勾分类节点(module)会自动级联勾选其下全部模块
|
||
* —— 由 G3Tree 的 checkable 非严格模式提供(点父级整棵子树开 / 关,半选态自动计算)。
|
||
* 落库只登记 data / virtual 模块编码:module 类型只是模块树上的分类,不参与绑定,
|
||
* 回写模型前统一过滤;树上的勾选草稿(含分类节点)只存在于本组件。
|
||
*
|
||
* 数据契约:v-model 双向绑定已绑模块编码数组。差异比对与落库(s_menu_module)
|
||
* 由父级负责 —— 本面板只维护草稿,随菜单的「保存」一起提交。
|
||
*
|
||
* 尚未移植:fms-vue 该面板右侧还有「权限点表 + 权限库」两块(就地增删 s_power、
|
||
* 从历史动作库导入)。本文件先只做模块绑定,那两块随权限相关步骤补。
|
||
*/
|
||
import { computed, ref, watch } from 'vue'
|
||
import { G3Tag } from '@g3soft/ui'
|
||
import type { TreeKey } from '@g3soft/ui'
|
||
import BizTree from '@/components/biz-tree/BizTree.vue'
|
||
import type { TreeRow } from '@/utils/tree'
|
||
import {
|
||
filterBindableModules,
|
||
isBindableModule,
|
||
MENU_BIND_MENU_TYPE,
|
||
moduleBindSignature,
|
||
} from './utils'
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
/** 模块树(module 分类 + data / virtual 数据模块),父级加载一次后传入 */
|
||
treeData?: TreeRow[]
|
||
/** 模块编码 → b_module_type;缺少映射的键按可绑定处理,避免误删历史绑定 */
|
||
moduleTypes?: Record<string, string>
|
||
/** 菜单类型(directory / page / external):只有页面菜单绑定模块 */
|
||
menuType?: string
|
||
/** 当前菜单标识:切换菜单时清掉组件内的勾选草稿 */
|
||
menuId?: string
|
||
/** 模块清单加载中 */
|
||
loading?: boolean
|
||
}>(),
|
||
{
|
||
treeData: () => [],
|
||
// 默认值必须是内联写法:defineProps 会被提升到 setup 之外,
|
||
// 引用模块级常量(即使是 const)编译期直接报错(checkInvalidScopeReference)。
|
||
// 这里的括号包裹空对象字面量:写成返回空表达式的箭头函数会让 props 变 undefined。
|
||
moduleTypes: () => ({}),
|
||
menuType: '',
|
||
menuId: '',
|
||
loading: false,
|
||
},
|
||
)
|
||
|
||
/** 已绑模块编码(s_menu_module 草稿,父级持有并随菜单保存一并提交) */
|
||
const modules = defineModel<string[]>({ default: () => [] })
|
||
|
||
/** 仅页面菜单绑定模块:目录 / 外链换一句说明,不渲染可勾的树 */
|
||
const canBind = computed(() => props.menuType === MENU_BIND_MENU_TYPE)
|
||
|
||
/** 不可绑定的原因(目录与外链分别说明,不共用一句含糊的「不可用」) */
|
||
const stateText = computed(() =>
|
||
props.menuType === 'directory' ? '目录不绑定模块' : '外链不绑定模块',
|
||
)
|
||
|
||
/**
|
||
* 树上的勾选草稿:含被勾中的分类节点(非严格模式下点父级会把整棵子树的键回传)。
|
||
* 外部值变化(切换菜单 / 重载 / 保存后清空)时按签名重置;本组件自身的回写签名一致,
|
||
* 因此不会重置 —— 刚点亮的分类节点不会因为回写模型而闪掉。
|
||
*/
|
||
const draftChecked = ref<string[]>([])
|
||
|
||
watch(
|
||
() => modules.value,
|
||
(value) => {
|
||
const next = (value || []).map(String)
|
||
if (moduleBindSignature(next) === moduleBindSignature(draftChecked.value)) return
|
||
draftChecked.value = next
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
/** 勾选变化:树上记全量(含分类节点),回写模型只留可绑定的 data / virtual */
|
||
function onCheckChange(keys: TreeKey[]): void {
|
||
draftChecked.value = keys.map(String)
|
||
modules.value = filterBindableModules(keys, props.moduleTypes)
|
||
}
|
||
|
||
/** 已绑数量(只算可绑定的,与落库口径一致) */
|
||
const boundCount = computed(
|
||
() =>
|
||
(modules.value || []).filter((key) => isBindableModule(props.moduleTypes[String(key)]))
|
||
.length,
|
||
)
|
||
|
||
/**
|
||
* 切换菜单:上一个菜单的勾选草稿不该留下。
|
||
* 未保存改动由父级在切换前用「有未保存改动」提示拦下,确认放弃后才走到这里。
|
||
*/
|
||
watch(
|
||
() => props.menuId,
|
||
() => {
|
||
draftChecked.value = []
|
||
},
|
||
)
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mm-bind">
|
||
<div class="mm-bind__head">
|
||
<span class="mm-bind__title">绑定模块</span>
|
||
<!-- 数字是已绑数量;不写「已绑 3 个模块」这类解释性文案 -->
|
||
<G3Tag v-if="canBind && boundCount" variant="secondary">{{ boundCount }}</G3Tag>
|
||
</div>
|
||
<div class="mm-bind__body">
|
||
<div v-if="!canBind" class="mm-bind__state">{{ stateText }}</div>
|
||
<div v-else class="mm-bind__tree">
|
||
<!-- 不设 default-expand-all:整棵树默认收起,由用户按需逐层点开 -->
|
||
<BizTree
|
||
v-model:checked-keys="draftChecked"
|
||
:tree-data="treeData"
|
||
:loading="loading"
|
||
checkable
|
||
@update:checked-keys="onCheckChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style lang="scss" scoped>
|
||
/* 白卡 + 卡头常驻 + 正文自己滚动,与「菜单配置」卡同构 */
|
||
.mm-bind {
|
||
height: 100%;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: var(--g3-color-bg-container, #fff);
|
||
border-radius: var(--g3-radius-base, 0.5rem);
|
||
}
|
||
|
||
.mm-bind__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex: none;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--g3-color-border, #e5e6eb);
|
||
}
|
||
|
||
.mm-bind__title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--g3-color-text, #1f2329);
|
||
}
|
||
|
||
.mm-bind__body {
|
||
flex: 1;
|
||
min-height: 0;
|
||
}
|
||
|
||
/* 模块树:撑满面板高度,长树在自己区域内滚动而不是把卡片顶高 */
|
||
.mm-bind__tree {
|
||
height: 100%;
|
||
overflow: auto;
|
||
padding: 6px 8px;
|
||
}
|
||
|
||
/* 目录 / 外链没有模块绑定:内容区中间给一句说明,空着一片反而像没加载出来 */
|
||
.mm-bind__state {
|
||
height: 100%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: var(--g3-color-text-tertiary, #86909c);
|
||
font-size: 13px;
|
||
}
|
||
</style>
|