task-58(账号/权限页面): 实现同级菜单排序交互
menu-manage-model.ts 增 siblingOrderAfterMove 同级内上下移(越界/缺失原序);menu-manage-api.ts
增 reorderMenus POST /api/admin/column/reorder({ordered_ids});MenusPage 每行上移/下移按钮
按同级组计算新顺序并提交后刷新。8 用例全过,427 单测 + build 绿。
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref } from 'vue'
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
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 {
|
import {
|
||||||
createMenuForm,
|
createMenuForm,
|
||||||
flattenMenuNodes,
|
flattenMenuNodes,
|
||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
menuFormFromNode,
|
menuFormFromNode,
|
||||||
menuParentChangeReason,
|
menuParentChangeReason,
|
||||||
nextSiblingSort,
|
nextSiblingSort,
|
||||||
|
siblingOrderAfterMove,
|
||||||
validateMenuManageForm,
|
validateMenuManageForm,
|
||||||
type MenuManageForm,
|
type MenuManageForm,
|
||||||
type MenuManageNode,
|
type MenuManageNode,
|
||||||
@@ -95,6 +96,20 @@ async function removeMenu(node: MenuManageNode) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同级上移/下移并提交排序;到达边界则忽略。 */
|
||||||
|
async function moveMenuRow(node: MenuManageNode, dir: -1 | 1) {
|
||||||
|
const flat = flattenMenuNodes(rows.value)
|
||||||
|
const group = flat.filter((n) => (n.parentId ?? null) === (node.parentId ?? null)).map((n) => n.id)
|
||||||
|
const ordered = siblingOrderAfterMove(group, node.id, dir)
|
||||||
|
if (ordered.join(',') === group.join(',')) return
|
||||||
|
try {
|
||||||
|
await reorderMenus(ordered)
|
||||||
|
await loadMenus()
|
||||||
|
} catch (error) {
|
||||||
|
ElMessage.error(error instanceof Error ? error.message : '排序保存失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadMenus)
|
onMounted(loadMenus)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -113,6 +128,12 @@ onMounted(loadMenus)
|
|||||||
<el-table-column prop="columnKey" label="权限标识" min-width="240" />
|
<el-table-column prop="columnKey" label="权限标识" min-width="240" />
|
||||||
<el-table-column prop="routePath" label="路由" min-width="260" />
|
<el-table-column prop="routePath" label="路由" min-width="260" />
|
||||||
<el-table-column prop="sortOrder" label="排序" width="100" />
|
<el-table-column prop="sortOrder" label="排序" width="100" />
|
||||||
|
<el-table-column label="移动" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="moveMenuRow(row, -1)">上移</el-button>
|
||||||
|
<el-button link type="primary" @click="moveMenuRow(row, 1)">下移</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="200">
|
<el-table-column label="操作" width="200">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from './menu-manage-model'
|
} from './menu-manage-model'
|
||||||
|
|
||||||
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
|
export const MENUS_ENDPOINT = '/api/admin/permission-menus'
|
||||||
|
export const REORDER_ENDPOINT = '/api/admin/column/reorder'
|
||||||
|
|
||||||
/** 拉取扁平可编辑菜单节点。 */
|
/** 拉取扁平可编辑菜单节点。 */
|
||||||
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
|
export async function fetchMenuManageNodes(): Promise<MenuManageNode[]> {
|
||||||
@@ -44,3 +45,9 @@ export async function deleteMenu(id: number): Promise<void> {
|
|||||||
const { data } = await http.delete<unknown>(`${MENUS_ENDPOINT}/${id}`)
|
const { data } = await http.delete<unknown>(`${MENUS_ENDPOINT}/${id}`)
|
||||||
unwrap<unknown>(data)
|
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 })
|
||||||
|
unwrap<unknown>(data)
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,6 +177,19 @@ export function menuDeleteReason(node: MenuManageNode | null | undefined): strin
|
|||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 同级拖拽/上下移:把 targetId 移动 dir 位(±1),返回新的同级 id 顺序;越界/缺失则原序。 */
|
||||||
|
export function siblingOrderAfterMove(group: number[], targetId: number, dir: number): number[] {
|
||||||
|
if (!Array.isArray(group) || group.length === 0) return []
|
||||||
|
const next = [...group]
|
||||||
|
const idx = next.indexOf(targetId)
|
||||||
|
if (idx < 0) return next
|
||||||
|
const to = idx + (dir === -1 || dir === 1 ? dir : 0)
|
||||||
|
if (to < 0 || to >= next.length) return next
|
||||||
|
const [moved] = next.splice(idx, 1)
|
||||||
|
next.splice(to, 0, moved)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
|
/** 新建/编辑表单空值工厂:默认菜单类型为 admin、排序 0、根父节点。 */
|
||||||
export function emptyMenuManageForm(): MenuManageForm {
|
export function emptyMenuManageForm(): MenuManageForm {
|
||||||
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
|
return { name: '', columnKey: '', parentId: null, menuType: ADMIN_MENU_TYPE, routePath: '', sortOrder: 0 }
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import test from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { readSource } from './helpers.ts'
|
||||||
|
import { siblingOrderAfterMove } from '../src/pages/account/menu-manage-model.ts'
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_normal_primary_path', () => {
|
||||||
|
// 正常主路径:同级内下移一位,返回新顺序 id 列表。
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1, 2, 3, 4], 2, 1), [1, 3, 2, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_normal_variant_input', () => {
|
||||||
|
// 正常变体:上移一位。
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1, 2, 3, 4], 3, -1), [1, 3, 2, 4])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_normal_repeated_operation_is_idempotent', () => {
|
||||||
|
// 正常重复:纯函数不改输入;结果稳定。
|
||||||
|
const group = [1, 2, 3]
|
||||||
|
assert.deepEqual(siblingOrderAfterMove(group, 1, 1), siblingOrderAfterMove(group, 1, 1))
|
||||||
|
assert.deepEqual(group, [1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_boundary_empty_input', () => {
|
||||||
|
// 边界空值:空组返回空,不崩溃。
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([], 1, 1), [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_boundary_single_item', () => {
|
||||||
|
// 边界单元素:单元素不可移。
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1], 1, 1), [1])
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1], 1, -1), [1])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_boundary_limit_or_missing_field', () => {
|
||||||
|
// 边界上限:已在首/尾则顺序不变。
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1, 2, 3], 1, -1), [1, 2, 3])
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1, 2, 3], 3, 1), [1, 2, 3])
|
||||||
|
assert.deepEqual(siblingOrderAfterMove([1, 2, 3], 9, 1), [1, 2, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_invalid_input_rejected', () => {
|
||||||
|
// 异常输入:非法方向按无操作处理;移动后集合元素不变。
|
||||||
|
const moved = siblingOrderAfterMove([2, 1, 3], 1, 0 as never)
|
||||||
|
assert.deepEqual(moved, [2, 1, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('test_task_058_menu_sort_dependency_failure_returns_actionable_message', () => {
|
||||||
|
// 依赖失败/排序走 adapter:POST /column/reorder 提交 ordered_ids,页面不内联请求。
|
||||||
|
const api = readSource('src/pages/account/menu-manage-api.ts')
|
||||||
|
assert.match(api, /reorderMenus|reorderColumns/)
|
||||||
|
assert.match(api, /column\/reorder/)
|
||||||
|
assert.match(api, /ordered_ids|orderedIds/)
|
||||||
|
const page = readSource('src/pages/account/MenusPage.vue')
|
||||||
|
assert.match(page, /siblingOrderAfterMove|reorderMenus|reorderColumns/)
|
||||||
|
assert.equal(/http\.post/.test(page), false, '页面不内联排序请求')
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user