align(菜单管理): 同级拖拽排序接线(手柄列+类型/父级分组+reorder带menu_type)、列表拉全量类型、类型文案后台/软件、弹窗补菜单类型下拉、父菜单候选按类型过滤(对齐 admin.js loadColumns/bindColumnDragSort/persistColumnOrder)

This commit is contained in:
2026-09-05 23:04:05 +08:00
parent 1692fa4a69
commit 32ef9a2ebb
6 changed files with 271 additions and 25 deletions
@@ -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>