238 lines
6.7 KiB
Vue
238 lines
6.7 KiB
Vue
<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>
|