20260904173136

This commit is contained in:
oneao committed 2026-09-04 17:31:36 +08:00
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>