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>
@@ -2,7 +2,6 @@
import { http } from '@/api/http'
import { unwrap } from '@/api/envelope'
import {
ADMIN_MENU_TYPE,
buildMenuManageTree,
parseMenuManageItem,
parseMenuManageList,
@@ -15,9 +14,9 @@ import {
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
export const REORDER_ENDPOINT = '/api/admin/column/reorder'
/** 拉取扁平可编辑菜单节点。 */
/** 拉取扁平可编辑菜单节点(全量类型,对齐 admin.js loadColumns 的 /api/admin/columns 语义)。 */
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
const { data } = await http.get<unknown>(MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } })
const { data } = await http.get<unknown>(MENUS_ENDPOINT)
return parseMenuManageList(data)
}
@@ -46,8 +45,8 @@ export async function deleteMenu(id: number): Promise<void> {
unwrap<unknown>(data)
}
/** 同级菜单排序:POST /api/admin/column/reorder 提交新的同级 id 顺序(含子树节点时仅同级生效)。 */
export async function reorderMenus(orderedIds: number[]): Promise<void> {
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { ordered_ids: orderedIds })
/** 同级菜单排序:POST /api/admin/column/reorder 提交 { menu_type, ordered_ids }(对齐 admin.js persistColumnOrder)。 */
export async function reorderMenus(menuType: string, orderedIds: number[]): Promise<void> {
const { data } = await http.post<unknown>(REORDER_ENDPOINT, { menu_type: menuType, ordered_ids: orderedIds })
unwrap<unknown>(data)
}
@@ -18,11 +18,17 @@ export interface MenuManageNode {
children?: MenuManageNode[]
}
/** 菜单类型展示文案(对齐 admin 菜单类型语义)。 */
/** 菜单类型展示文案(对齐 admin.js:6824admin → 后台,其余一律 → 软件)。 */
export function menuTypeLabel(type: string | null | undefined): string {
return (type || '').toLowerCase() === 'app' ? '应用菜单' : '后台菜单'
return (type || '').toLowerCase() === 'admin' ? '后台' : '软件'
}
/** 菜单类型下拉选项(对齐 admin.html:5931-5934 后台/软件)。 */
export const MENU_TYPE_OPTIONS: Array<{ value: 'admin' | 'app'; label: string }> = [
{ value: 'admin', label: '后台' },
{ value: 'app', label: '软件' },
]
export interface MenuManageForm {
name: string
columnKey: string
@@ -198,6 +204,55 @@ export function siblingOrderAfterMove(group: number[], targetId: number, dir: nu
return next
}
/** 拖拽分组键:同"菜单类型 + 父级"才算同级(对齐 admin.js columnSiblingGroupKey)。 */
export function menuSiblingGroupKey(node: MenuManageNode): string {
return `${node.menuType || 'app'}|${node.parentId ?? ''}`
}
/** 拖放排序:把 draggedId 插到 targetId 前/后,返回新的同级 id 顺序;任一 id 缺失或同 id 则原序。 */
export function siblingOrderAfterDrop(
group: number[],
draggedId: number,
targetId: number,
placeAfter: boolean,
): number[] {
if (!Array.isArray(group) || group.length === 0) return []
if (draggedId === targetId) return [...group]
const next = [...group]
const from = next.indexOf(draggedId)
if (from < 0) return next
const target = next.indexOf(targetId)
if (target < 0) return next
next.splice(from, 1)
const insertAt = next.indexOf(targetId) + (placeAfter ? 1 : 0)
next.splice(insertAt, 0, draggedId)
return next
}
/** 父菜单候选:仅同菜单类型的节点(对齐 admin 的类型联动父级下拉)。 */
export function menuParentCandidates(nodes: MenuManageNode[], menuType: string): MenuManageNode[] {
const type = (menuType || '').trim()
return flattenMenuNodes(nodes).filter((node) => (node.menuType || '') === type)
}
/** 同级重排请求体(对齐 admin.js persistColumnOrder{ menu_type, ordered_ids })。 */
export function toMenuReorderRequest(
menuType: string,
orderedIds: number[],
): { menu_type: string; ordered_ids: number[] } {
return { menu_type: text(menuType) || 'app', ordered_ids: dedupeOrderIds(orderedIds) }
}
function dedupeOrderIds(ids: unknown[]): number[] {
if (!Array.isArray(ids)) return []
const set = new Set<number>()
for (const v of ids) {
const n = numberOrNull(v)
if (n !== null && n > 0) set.add(n)
}
return [...set]
}
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
export function emptyMenuManageForm(): MenuManageForm {
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
@@ -0,0 +1,87 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readSource } from './helpers.ts'
import {
MENU_TYPE_OPTIONS,
menuParentCandidates,
menuSiblingGroupKey,
menuTypeLabel,
siblingOrderAfterDrop,
toMenuReorderRequest,
type MenuManageNode,
} from '../src/pages/account/menu-manage-model.ts'
/** 对齐 admin.js:6799-6830(全量列/类型文案 后台|软件)与 6882-6964(同级拖拽 + reorder 载荷)。 */
function node(id: number, menuType = 'admin', parentId: number | null = null): MenuManageNode {
return { id, name: `菜单${id}`, columnKey: `k${id}`, parentId, menuType, routePath: `/r/${id}`, sortOrder: id }
}
test('align_menu_type_label_matches_reference', () => {
// 参考:admin → 后台,其余一律 → 软件(含空值)。
assert.equal(menuTypeLabel('admin'), '后台')
assert.equal(menuTypeLabel('app'), '软件')
assert.equal(menuTypeLabel('APP'), '软件')
assert.equal(menuTypeLabel(null), '软件')
assert.equal(menuTypeLabel(undefined), '软件')
})
test('align_menu_type_options_backend_software', () => {
assert.deepEqual(MENU_TYPE_OPTIONS, [
{ value: 'admin', label: '后台' },
{ value: 'app', label: '软件' },
])
})
test('align_menu_sibling_group_key_is_type_plus_parent', () => {
assert.equal(menuSiblingGroupKey(node(1, 'admin', 5)), 'admin|5')
assert.equal(menuSiblingGroupKey(node(2, 'app', null)), 'app|')
assert.equal(menuSiblingGroupKey(node(3, 'app', 5)), 'app|5')
})
test('align_menu_sibling_order_after_drop_before_target', () => {
const group = [1, 2, 3, 4]
assert.deepEqual(siblingOrderAfterDrop(group, 4, 2, false), [1, 4, 2, 3])
})
test('align_menu_sibling_order_after_drop_after_target', () => {
const group = [1, 2, 3, 4]
assert.deepEqual(siblingOrderAfterDrop(group, 4, 2, true), [1, 2, 4, 3])
})
test('align_menu_sibling_order_after_drop_invalid_returns_original', () => {
const group = [1, 2, 3]
assert.deepEqual(siblingOrderAfterDrop(group, 9, 2, false), group)
assert.deepEqual(siblingOrderAfterDrop(group, 1, 9, true), group)
assert.deepEqual(siblingOrderAfterDrop(group, 2, 2, false), group)
assert.deepEqual(siblingOrderAfterDrop([], 1, 2, false), [])
})
test('align_menu_parent_candidates_same_type_only', () => {
const nodes = [node(1, 'admin', null), node(2, 'app', null), node(3, 'admin', 1), node(4, 'app', 2)]
assert.deepEqual(menuParentCandidates(nodes, 'admin').map((n) => n.id), [1, 3])
assert.deepEqual(menuParentCandidates(nodes, 'app').map((n) => n.id), [2, 4])
})
test('align_menu_reorder_request_includes_menu_type', () => {
// 参考 persistColumnOrder 载荷:{ menu_type, ordered_ids }。
assert.deepEqual(toMenuReorderRequest('admin', [1, 2, 3]), { menu_type: 'admin', ordered_ids: [1, 2, 3] })
assert.deepEqual(toMenuReorderRequest('', [1]), { menu_type: 'app', ordered_ids: [1] })
})
test('align_menu_list_loads_all_types_no_filter', () => {
// 参考 loadColumns 拉全量列(无 menuType 过滤),类型由列内区分。
const api = readSource('src/pages/account/menu-manage-api.ts')
assert.doesNotMatch(api, /params:\s*\{\s*menuType/, '列表请求不再按 admin 过滤')
assert.match(api, /ordered_ids/, '重排载荷带 ordered_ids')
assert.match(api, /menu_type/, '重排载荷带 menu_type')
})
test('align_menu_page_drag_and_type_wiring', () => {
const page = readSource('src/pages/account/MenusPage.vue')
assert.match(page, /拖动每行左侧的手柄可调整同级菜单的显示顺序,松开后自动保存/, '顶部拖拽提示文案对齐')
assert.match(page, /draggable/, '行手柄可拖拽')
assert.match(page, /menuSiblingGroupKey/, '拖拽分组按类型+父级')
assert.match(page, /MENU_TYPE_OPTIONS|菜单类型/, '新建/编辑弹窗含菜单类型下拉')
assert.match(page, /menuParentCandidates/, '父菜单候选按类型过滤')
})
+6 -6
View File
@@ -24,16 +24,16 @@ test('test_task_252_menus_list_normal_variant_input', () => {
})
test('test_task_252_menus_list_normal_repeated_operation_is_idempotent', () => {
// 类型映射幂等。
assert.equal(menuTypeLabel('admin'), '后台菜单')
assert.equal(menuTypeLabel('APP'), '应用菜单')
// 类型映射幂等(对齐 admin.js:admin→后台,其余→软件)
assert.equal(menuTypeLabel('admin'), '后台')
assert.equal(menuTypeLabel('APP'), '软件')
assert.equal(menuTypeLabel('app'), menuTypeLabel('app'))
})
test('test_task_252_menus_list_boundary_empty_input', () => {
// 边界:未知类型归后台菜单;创建/编辑标题区分。
assert.equal(menuTypeLabel(null), '后台菜单')
assert.equal(menuTypeLabel(undefined), '后台菜单')
// 边界:未知类型归软件;创建/编辑标题区分。
assert.equal(menuTypeLabel(null), '软件')
assert.equal(menuTypeLabel(undefined), '软件')
const page = readSource('src/pages/account/MenusPage.vue')
assert.match(page, /新增菜单/, '页头新增菜单入口')
assert.match(page, /editingNode.*编辑菜单|编辑菜单/, '编辑态标题')
+3 -1
View File
@@ -79,5 +79,7 @@ test('test_task_054_menu_manage_list_dependency_failure_returns_actionable_messa
assert.match(api, /permission-menus/)
assert.match(api, /parseMenuManageList/)
assert.match(api, /buildMenuManageTree/)
assert.match(api, /menuType:\s*ADMIN_MENU_TYPE|ADMIN_MENU_TYPE/)
// 对齐 admin.js loadColumns:列表拉全量菜单(不再按 admin 类型过滤),类型在行内区分。
assert.doesNotMatch(api, /menuType:\s*ADMIN_MENU_TYPE/, '列表请求不再过滤 menuType')
assert.match(api, /menu_type/, '重排载荷带 menu_type')
})