20260904173136
This commit is contained in:
1 parent
0928437c67
commit
4e167df0f0
40 files changed
+3471
-2314
No files matched your search
@@ -0,0 +1,262 @@
|
||||
<script setup>
|
||||
/**
|
||||
* 菜单树面板(menu-management 左侧)。
|
||||
*
|
||||
* 职责:搜索框与树展示,展开 / 选中 / 拖拽 / 右键菜单的 UI 交互,节点启用停用的操作入口。
|
||||
* 数据契约:树数据、选中项、未保存标记均由父级唯一持有,本组件只读并通过事件上报意图:
|
||||
* select / add / delete / drop / refresh / toggleEnable。
|
||||
* 搜索关键字与展开键是纯 UI 状态,留在本组件;展开键需由父级在新建菜单后追加祖先路径,
|
||||
* 故以 v-model:expanded-keys 暴露。
|
||||
*/
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ExternalLink, FileText, Folder, Plus, RefreshCw, Search, Trash2 } from '@lucide/vue'
|
||||
import { Button, Checkbox, Dropdown, Input, Tree } from '@/components/ui'
|
||||
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',
|
||||
'refresh',
|
||||
'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">
|
||||
<Button
|
||||
type="ghost"
|
||||
class="mm-tree-header__icon-btn"
|
||||
title="刷新菜单树"
|
||||
aria-label="刷新菜单树"
|
||||
@click="emit('refresh')"
|
||||
>
|
||||
<RefreshCw :size="14" />
|
||||
</Button>
|
||||
<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">
|
||||
<div v-if="loading" class="mm-tree-empty">加载中...</div>
|
||||
<!-- 过滤(搜索)状态下禁止拖拽:此时树是浅拷贝视图,落点索引与原树不一致 -->
|
||||
<Tree
|
||||
v-else
|
||||
:tree-data="filteredTree"
|
||||
:field-names="{ key: 'b_id', title: 'b_name', children: 'children' }"
|
||||
: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 }">
|
||||
<span class="mm-tree-node">
|
||||
<span
|
||||
class="mm-tree-node__name"
|
||||
:class="{ 'is-disabled': Number(node.b_canuse) !== 1 }"
|
||||
>
|
||||
{{ node.b_name || node.b_code || '(未命名)' }}
|
||||
</span>
|
||||
<Checkbox
|
||||
class="mm-tree-node__enable"
|
||||
:model-value="Number(node.b_canuse) === 1"
|
||||
@click.stop
|
||||
@change="(checked) => emit('toggleEnable', { node, checked })"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</Tree>
|
||||
</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;
|
||||
}
|
||||
|
||||
.mm-tree-empty {
|
||||
padding: 20px 0;
|
||||
text-align: center;
|
||||
color: var(--fms-text-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user