From 32ef9a2ebb6643036b41e2094e79ee15c092734e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 5 Sep 2026 23:04:05 +0800 Subject: [PATCH] =?UTF-8?q?align(=E8=8F=9C=E5=8D=95=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=90=8C=E7=BA=A7=E6=8B=96=E6=8B=BD=E6=8E=92=E5=BA=8F=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF(=E6=89=8B=E6=9F=84=E5=88=97+=E7=B1=BB=E5=9E=8B/?= =?UTF-8?q?=E7=88=B6=E7=BA=A7=E5=88=86=E7=BB=84+reorder=E5=B8=A6menu=5Ftyp?= =?UTF-8?q?e)=E3=80=81=E5=88=97=E8=A1=A8=E6=8B=89=E5=85=A8=E9=87=8F?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E3=80=81=E7=B1=BB=E5=9E=8B=E6=96=87=E6=A1=88?= =?UTF-8?q?=E5=90=8E=E5=8F=B0/=E8=BD=AF=E4=BB=B6=E3=80=81=E5=BC=B9?= =?UTF-8?q?=E7=AA=97=E8=A1=A5=E8=8F=9C=E5=8D=95=E7=B1=BB=E5=9E=8B=E4=B8=8B?= =?UTF-8?q?=E6=8B=89=E3=80=81=E7=88=B6=E8=8F=9C=E5=8D=95=E5=80=99=E9=80=89?= =?UTF-8?q?=E6=8C=89=E7=B1=BB=E5=9E=8B=E8=BF=87=E6=BB=A4=EF=BC=88=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=20admin.js=20loadColumns/bindColumnDragSort/persistCo?= =?UTF-8?q?lumnOrder=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/account/MenusPage.vue | 123 ++++++++++++++++-- .../src/pages/account/menu-manage-api.ts | 11 +- .../src/pages/account/menu-manage-model.ts | 59 ++++++++- .../tests/align-menu-manage.test.ts | 87 +++++++++++++ admin-frontend-vue/tests/task-252.test.ts | 12 +- admin-frontend-vue/tests/task-54.test.ts | 4 +- 6 files changed, 271 insertions(+), 25 deletions(-) create mode 100644 admin-frontend-vue/tests/align-menu-manage.test.ts diff --git a/admin-frontend-vue/src/pages/account/MenusPage.vue b/admin-frontend-vue/src/pages/account/MenusPage.vue index d3bf45d5..3bade327 100644 --- a/admin-frontend-vue/src/pages/account/MenusPage.vue +++ b/admin-frontend-vue/src/pages/account/MenusPage.vue @@ -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(null) const menuForm = reactive(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 { + 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) 新增菜单 + + + + - + - @@ -155,8 +228,13 @@ onMounted(loadMenus) + + + + + - + + + diff --git a/admin-frontend-vue/src/pages/account/menu-manage-api.ts b/admin-frontend-vue/src/pages/account/menu-manage-api.ts index abb47355..64017baa 100644 --- a/admin-frontend-vue/src/pages/account/menu-manage-api.ts +++ b/admin-frontend-vue/src/pages/account/menu-manage-api.ts @@ -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 { - const { data } = await http.get(MENUS_ENDPOINT, { params: { menuType: ADMIN_MENU_TYPE } }) + const { data } = await http.get(MENUS_ENDPOINT) return parseMenuManageList(data) } @@ -46,8 +45,8 @@ export async function deleteMenu(id: number): Promise { unwrap(data) } -/** 同级菜单排序:POST /api/admin/column/reorder 提交新的同级 id 顺序(含子树节点时仅同级生效)。 */ -export async function reorderMenus(orderedIds: number[]): Promise { - const { data } = await http.post(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 { + const { data } = await http.post(REORDER_ENDPOINT, { menu_type: menuType, ordered_ids: orderedIds }) unwrap(data) } diff --git a/admin-frontend-vue/src/pages/account/menu-manage-model.ts b/admin-frontend-vue/src/pages/account/menu-manage-model.ts index 8ccebed6..eedd6321 100644 --- a/admin-frontend-vue/src/pages/account/menu-manage-model.ts +++ b/admin-frontend-vue/src/pages/account/menu-manage-model.ts @@ -18,11 +18,17 @@ export interface MenuManageNode { children?: MenuManageNode[] } -/** 菜单类型展示文案(对齐 admin 菜单类型语义)。 */ +/** 菜单类型展示文案(对齐 admin.js:6824:admin → 后台,其余一律 → 软件)。 */ 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() + 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 } diff --git a/admin-frontend-vue/tests/align-menu-manage.test.ts b/admin-frontend-vue/tests/align-menu-manage.test.ts new file mode 100644 index 00000000..aff9480f --- /dev/null +++ b/admin-frontend-vue/tests/align-menu-manage.test.ts @@ -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/, '父菜单候选按类型过滤') +}) diff --git a/admin-frontend-vue/tests/task-252.test.ts b/admin-frontend-vue/tests/task-252.test.ts index 0be7cb12..18a3b8dc 100644 --- a/admin-frontend-vue/tests/task-252.test.ts +++ b/admin-frontend-vue/tests/task-252.test.ts @@ -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.*编辑菜单|编辑菜单/, '编辑态标题') diff --git a/admin-frontend-vue/tests/task-54.test.ts b/admin-frontend-vue/tests/task-54.test.ts index 0d7ed6a1..9fb2037a 100644 --- a/admin-frontend-vue/tests/task-54.test.ts +++ b/admin-frontend-vue/tests/task-54.test.ts @@ -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') })