Files
workspace/code/fms/fms-vue/src/views/module/menu-management/MenuTreePanel.vue
T
2026-09-15 22:10:13 +08:00

238 lines
6.7 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>
/**
* 菜单树面板(menu-management 左侧)。
*
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互,节点启用停用的操作入口。
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
* select / add / delete / drop / toggleEnable。
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建菜单后追加祖先路径,
* 故以 v-model:expanded-keys 暴露。
*/
import { computed, ref, watch } from 'vue'
import { ExternalLink, FileText, Folder, Plus, Search, Trash2 } from '@lucide/vue'
import { Button, Checkbox, Dropdown, Input } from '@/components/ui'
import FmsTree from '@/components/fms-tree/FmsTree.vue'
import { collectAllKeys, filterTreeByText } from '@/utils/tree'
const props = defineProps({
/** 菜单树(s_menu 全量),父级持有并负责加载 / 保存 */
treeData: { type: Array, required: true },
/** 树数据加载中 */
loading: { type: Boolean, default: false },
/** 当前选中菜单 id */
selectedId: { type: [String, Number], default: null },
/** 展开的树节点 key(v-model:新建菜单后由父级追加祖先路径) */
expandedKeys: { type: Array, default: () => [] },
/** 未落库(新建未保存)的菜单 id 集合;父级唯一状态源,本组件只读 */
unsavedIds: { type: Set, default: () => new Set() },
})
const emit = defineEmits(['update:expandedKeys', 'select', 'add', 'delete', 'drop', 'toggleEnable'])
/** 搜索关键字(纯 UI 状态) */
const keyword = ref('')
const filteredTree = computed(() => filterTreeByText(props.treeData, keyword.value))
// 搜索时展开全部(保证命中节点可见),清空时收起
watch(keyword, (kw) => {
emit('update:expandedKeys', kw.trim() ? collectAllKeys(props.treeData) : [])
})
const addOptions = [
{ key: 'directory', label: '新增目录', icon: Folder },
{ key: 'page', label: '新增页面', icon: FileText },
{ key: 'external', label: '新增外链', icon: ExternalLink },
]
/** 选中:仅上报目标 id,是否需要未保存确认由父级决定 */
function onSelect(keys) {
if (!keys.length) return
const nextId = String(keys[0])
if (nextId === String(props.selectedId)) return
emit('select', nextId)
}
/**
* 右键菜单项数据源。
* 未保存(草稿)节点不能作为父级(其 id 尚未落库,子菜单会凭空挂在临时 id 下)。
*/
function buildContextMenu(node) {
const isUnsaved = node != null && props.unsavedIds.has(String(node.b_id))
const items = addOptions.map((option) => ({ ...option, disabled: isUnsaved }))
if (node) {
items.push({ divider: true })
items.push({ key: 'delete', label: '删除', icon: Trash2, danger: true })
}
return items
}
function onContextMenuClick({ item, node }) {
const parentId = node ? String(node.b_id) : null
if (item.key === 'delete') emit('delete', parentId)
else emit('add', { parentId, menuType: item.key })
}
/** 工具栏新增:挂到当前选中节点下,无选中则顶级 */
function onToolbarAdd({ key }) {
emit('add', { parentId: props.selectedId ? String(props.selectedId) : null, menuType: key })
}
</script>
<template>
<aside class="mm-tree-panel">
<div class="mm-tree-header">
<Input v-model="keyword" allow-clear class="mm-tree-search">
<template #prefix><Search :size="14" class="mm-tree-search__icon" /></template>
</Input>
<div class="mm-tree-header__actions">
<Dropdown :items="addOptions" @click="onToolbarAdd">
<Button
type="ghost"
class="mm-tree-header__icon-btn"
title="新增菜单"
aria-label="新增菜单"
>
<Plus :size="14" />
</Button>
</Dropdown>
</div>
</div>
<div class="mm-tree-panel__scroll">
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
<FmsTree
:tree-data="filteredTree"
:loading="loading"
:expanded-keys="expandedKeys"
@update:expanded-keys="(keys) => emit('update:expandedKeys', keys)"
:selected-keys="selectedId ? [selectedId] : []"
:context-menu="buildContextMenu"
:draggable="!keyword.trim()"
@select="onSelect"
@context-menu-click="onContextMenuClick"
@drop="(payload) => emit('drop', payload)"
>
<template #title="{ node, title }">
<span class="mm-tree-node">
<span
class="mm-tree-node__name"
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
>
{{ title }}
</span>
<Checkbox
class="mm-tree-node__enable"
:model-value="Number(node.b_canuse) === 1"
@click.stop
@change="(checked) => emit('toggleEnable', { node, checked })"
/>
</span>
</template>
</FmsTree>
</div>
</aside>
</template>
<style lang="scss" scoped>
/* ---- 左侧树面板 ---- */
.mm-tree-panel {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
height: 100%;
min-height: 0;
padding: 8px;
background: var(--fms-card);
border-radius: var(--fms-control-radius);
box-shadow: var(--fms-shadow);
overflow: hidden;
}
.mm-tree-panel__scroll {
flex: 1;
min-height: 0;
overflow: auto;
}
.mm-tree-header {
display: flex;
align-items: center;
gap: 6px;
padding: 0;
}
.mm-tree-search {
flex: 1;
min-width: 0;
}
.mm-tree-search__icon {
color: var(--fms-text-secondary);
}
.mm-tree-header__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.mm-tree-header__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--fms-border);
border-radius: var(--fms-control-radius);
background: var(--fms-card);
color: var(--fms-text-secondary);
cursor: pointer;
transition:
border-color 150ms ease,
background 150ms ease,
color 150ms ease;
&:hover:not(:disabled) {
background: var(--fms-secondary);
color: var(--fms-text);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
/* ---- 树节点 ---- */
.mm-tree-node {
flex: 1;
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 2px 0;
line-height: 1.3;
}
.mm-tree-node__name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.is-disabled {
color: var(--fms-text-disabled, #9ca3af);
}
}
/* 树节点启用勾选框:靠右、不随文字缩放 */
.mm-tree-node__enable {
flex-shrink: 0;
margin-left: auto;
}
</style>