align(菜单管理): 同级拖拽排序接线(手柄列+类型/父级分组+reorder带menu_type)、列表拉全量类型、类型文案后台/软件、弹窗补菜单类型下拉、父菜单候选按类型过滤(对齐 admin.js loadColumns/bindColumnDragSort/persistColumnOrder)
This commit is contained in:
@@ -2,15 +2,19 @@
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { createMenu, deleteMenu, loadMenuManageTree, updateMenu } from './menu-manage-api'
|
||||
import { createMenu, deleteMenu, loadMenuManageTree, reorderMenus, updateMenu } from './menu-manage-api'
|
||||
import {
|
||||
MENU_TYPE_OPTIONS,
|
||||
createMenuForm,
|
||||
flattenMenuNodes,
|
||||
menuDeleteReason,
|
||||
menuFormFromNode,
|
||||
menuParentCandidates,
|
||||
menuParentChangeReason,
|
||||
menuSiblingGroupKey,
|
||||
menuTypeLabel,
|
||||
nextSiblingSort,
|
||||
siblingOrderAfterDrop,
|
||||
validateMenuManageForm,
|
||||
type MenuManageForm,
|
||||
type MenuManageNode,
|
||||
@@ -22,7 +26,7 @@ const createVisible = ref(false)
|
||||
const saving = ref(false)
|
||||
const editingNode = ref<MenuManageNode | null>(null)
|
||||
const menuForm = reactive<MenuManageForm>(createMenuForm(null, 0))
|
||||
const parentOptions = computed(() => flattenMenuNodes(rows.value))
|
||||
const parentOptions = computed(() => menuParentCandidates(rows.value, menuForm.menuType))
|
||||
const dialogTitle = computed(() => (editingNode.value ? '编辑菜单' : '新增菜单'))
|
||||
/** 节点 id -> 名称,供「上级菜单」列展示。 */
|
||||
const nameById = computed(() => {
|
||||
@@ -31,8 +35,11 @@ const nameById = computed(() => {
|
||||
return map
|
||||
})
|
||||
|
||||
/** 拖拽状态:同"菜单类型+父级"分组内才可落位(对齐 admin.js columnDragState)。 */
|
||||
const dragState = ref<{ id: number; groupKey: string; menuType: string } | null>(null)
|
||||
|
||||
function parentNameOf(node: MenuManageNode): string {
|
||||
if (node.parentId == null) return '—'
|
||||
if (node.parentId == null) return '-'
|
||||
return nameById.value.get(node.parentId) ?? `#${node.parentId}`
|
||||
}
|
||||
|
||||
@@ -59,6 +66,58 @@ function openEdit(node: MenuManageNode) {
|
||||
createVisible.value = true
|
||||
}
|
||||
|
||||
/** 菜单类型切换时:当前父菜单不属于新类型则重置为根。 */
|
||||
function onMenuTypeChange(): void {
|
||||
const candidates = menuParentCandidates(rows.value, menuForm.menuType)
|
||||
if (menuForm.parentId != null && !candidates.some((node) => node.id === menuForm.parentId)) {
|
||||
menuForm.parentId = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(event: DragEvent, node: MenuManageNode): void {
|
||||
dragState.value = { id: node.id, groupKey: menuSiblingGroupKey(node), menuType: node.menuType }
|
||||
if (event.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move'
|
||||
try {
|
||||
event.dataTransfer.setData('text/plain', String(node.id))
|
||||
} catch {
|
||||
// 某些浏览器 setData 受限,忽略即可。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragEnd(): void {
|
||||
dragState.value = null
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent, node: MenuManageNode): void {
|
||||
const state = dragState.value
|
||||
if (!state || state.groupKey !== menuSiblingGroupKey(node) || state.id === node.id) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move'
|
||||
}
|
||||
|
||||
async function onDrop(event: DragEvent, target: MenuManageNode): Promise<void> {
|
||||
const state = dragState.value
|
||||
dragState.value = null
|
||||
if (!state || state.groupKey !== menuSiblingGroupKey(target) || state.id === target.id) return
|
||||
event.preventDefault()
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const placeAfter = event.clientY - rect.top > rect.height / 2
|
||||
const groupIds = flattenMenuNodes(rows.value)
|
||||
.filter((node) => menuSiblingGroupKey(node) === state.groupKey)
|
||||
.map((node) => node.id)
|
||||
const next = siblingOrderAfterDrop(groupIds, state.id, target.id, placeAfter)
|
||||
if (next.join(',') === groupIds.join(',')) return
|
||||
try {
|
||||
await reorderMenus(state.menuType, next)
|
||||
ElMessage.success('排序已保存')
|
||||
await loadMenus()
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMenu() {
|
||||
if (editingNode.value) {
|
||||
const reason = menuParentChangeReason(editingNode.value, menuForm.parentId)
|
||||
@@ -121,27 +180,41 @@ onMounted(loadMenus)
|
||||
<el-button type="primary" @click="openCreate">新增菜单</el-button>
|
||||
</div>
|
||||
<el-card shadow="never">
|
||||
<p class="menu-drag-tip">拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存。</p>
|
||||
<el-table v-loading="loading" :data="rows" row-key="id" default-expand-all stripe>
|
||||
<el-table-column label="排序" width="64">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
class="menu-drag-handle"
|
||||
draggable="true"
|
||||
role="button"
|
||||
:title="`拖动调整“${(row as MenuManageNode).name}”的顺序`"
|
||||
@dragstart="onDragStart($event, row as MenuManageNode)"
|
||||
@dragend="onDragEnd"
|
||||
@dragover="onDragOver($event, row as MenuManageNode)"
|
||||
@drop="onDrop($event, row as MenuManageNode)"
|
||||
>⠿</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="name" label="菜单名称" min-width="180" />
|
||||
<el-table-column label="菜单类型" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel(row.menuType) }}</el-tag>
|
||||
<el-tag size="small" :type="(row as MenuManageNode).menuType === 'app' ? 'info' : 'primary'">{{ menuTypeLabel((row as MenuManageNode).menuType) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上级菜单" min-width="120">
|
||||
<template #default="{ row }">
|
||||
{{ parentNameOf(row) }}
|
||||
{{ parentNameOf(row as MenuManageNode) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="150">
|
||||
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
|
||||
<template #default="{ row }">{{ formatDateTime((row as MenuManageNode).createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sortOrder" label="排序" width="90" />
|
||||
<el-table-column label="操作" width="160" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="menuDeleteReason(row) !== undefined" @click="removeMenu(row)">删除</el-button>
|
||||
<el-button link type="primary" @click="openEdit(row as MenuManageNode)">编辑</el-button>
|
||||
<el-button link type="danger" :disabled="menuDeleteReason(row as MenuManageNode) !== undefined" @click="removeMenu(row as MenuManageNode)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -155,8 +228,13 @@ onMounted(loadMenus)
|
||||
<el-form-item label="权限标识">
|
||||
<el-input v-model="menuForm.columnKey" placeholder="如:admin_users(唯一,稳定授权标识)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="菜单类型">
|
||||
<el-select v-model="menuForm.menuType" style="width: 100%" @change="onMenuTypeChange">
|
||||
<el-option v-for="opt in MENU_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="父菜单">
|
||||
<el-select v-model="menuForm.parentId" clearable placeholder="作为根菜单" style="width: 100%">
|
||||
<el-select v-model="menuForm.parentId" clearable placeholder="无(一级菜单)" style="width: 100%">
|
||||
<el-option
|
||||
v-for="node in parentOptions"
|
||||
:key="node.id"
|
||||
@@ -179,3 +257,28 @@ onMounted(loadMenus)
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.menu-drag-tip {
|
||||
margin: 0 0 10px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.menu-drag-handle {
|
||||
display: inline-block;
|
||||
cursor: grab;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
}
|
||||
.menu-drag-handle:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
.menu-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user