From ed37d5f955d0f145ef65757dcdb798aeef168e4c Mon Sep 17 00:00:00 2001 From: andy Date: Thu, 16 Jul 2026 12:47:21 +0700 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=B3=BB=E7=BB=9F=E8=8F=9C?= =?UTF-8?q?=E5=8D=95=E6=A0=91=E7=AE=A1=E7=90=86=E7=AC=AC=E4=B8=80=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/i18n/locales/en-US.ts | 20 + client/src/i18n/locales/th-TH.ts | 20 + client/src/i18n/locales/zh-CN.ts | 20 + client/src/services/systemAdminService.ts | 12 + client/src/tests/systemAdminService.spec.ts | 65 ++ client/src/tests/systemMenusView.spec.ts | 248 +++++ client/src/types/systemAdmin.ts | 19 + client/src/views/system/SystemMenusView.vue | 1111 +++++++++++++------ client/src/views/system/system-admin.css | 211 +++- 9 files changed, 1410 insertions(+), 316 deletions(-) create mode 100644 client/src/tests/systemMenusView.spec.ts diff --git a/client/src/i18n/locales/en-US.ts b/client/src/i18n/locales/en-US.ts index 24fa967..1567fc3 100644 --- a/client/src/i18n/locales/en-US.ts +++ b/client/src/i18n/locales/en-US.ts @@ -56,6 +56,9 @@ export default { nextPage: 'Next', pageStatus: 'Page {current} / {total}', pageSize: 'Page size', + refresh: 'Refresh', + saving: 'Saving', + cancel: 'Cancel', }, notFound: { title: 'Page unavailable', @@ -130,16 +133,33 @@ export default { }, menus: { title: 'Menu filters', + managementTitle: 'Menu management', + managementHint: 'Maintain sidebar menu hierarchy, order, visibility, and permission entry points.', createTitle: 'Create menu', editTitle: 'Edit menu', tableTitle: 'Menus', totalSuffix: 'menus', keywordPlaceholder: 'Menu code / name / route', menuStatus: 'Menu status', + visibleOnly: 'Visible only', + treeWarningTitle: 'Menu tree has configuration warnings', + treeWarningHint: 'The backend returned the menu tree, but these items need administrator review.', + treeTitle: 'Menu tree', + treeDirty: 'Tree changes are not saved', + treeDirtyActionBlocked: 'Save the menu tree before continuing.', + treeWarningSaveBlocked: 'Resolve the menu tree warnings and confirm parent relationships before saving the tree.', + saveTreeOrder: 'Save tree', menuCode: 'Menu code', menuName: 'Menu name', + parentMenu: 'Parent menu', + rootMenu: 'Root menu', componentKey: 'Component key', iconKey: 'Icon key', + unknownRoute: 'Unknown route', + unknownRouteHint: 'This route is not recognized by the frontend and may open an unavailable page.', + permissionLoadFailed: 'Permission list failed to load. Permission selection and menu config saving are temporarily unavailable.', + moveUp: 'Move up', + moveDown: 'Move down', columns: { menu: 'Menu', route: 'Route', diff --git a/client/src/i18n/locales/th-TH.ts b/client/src/i18n/locales/th-TH.ts index 9036eed..c0934ae 100644 --- a/client/src/i18n/locales/th-TH.ts +++ b/client/src/i18n/locales/th-TH.ts @@ -56,6 +56,9 @@ export default { nextPage: 'หน้าถัดไป', pageStatus: 'หน้า {current} / {total}', pageSize: 'ต่อหน้า', + refresh: 'รีเฟรช', + saving: 'กำลังบันทึก', + cancel: 'ยกเลิก', }, notFound: { title: 'หน้านี้ยังไม่พร้อม', @@ -130,16 +133,33 @@ export default { }, menus: { title: 'ตัวกรองเมนู', + managementTitle: 'จัดการเมนู', + managementHint: 'ดูแลลำดับชั้น ลำดับการแสดง การมองเห็น และสิทธิ์ของเมนูด้านข้าง', createTitle: 'สร้างเมนู', editTitle: 'แก้ไขเมนู', tableTitle: 'ข้อมูลเมนู', totalSuffix: 'เมนู', keywordPlaceholder: 'รหัสเมนู / ชื่อ / เส้นทาง', menuStatus: 'สถานะเมนู', + visibleOnly: 'ดูเฉพาะเมนูที่แสดง', + treeWarningTitle: 'โครงสร้างเมนูมีการตั้งค่าที่ต้องตรวจสอบ', + treeWarningHint: 'หลังบ้านส่งโครงสร้างเมนูกลับมาแล้ว แต่รายการต่อไปนี้ต้องให้ผู้ดูแลตรวจสอบ', + treeTitle: 'โครงสร้างเมนู', + treeDirty: 'มีการปรับโครงสร้างที่ยังไม่ได้บันทึก', + treeDirtyActionBlocked: 'โปรดบันทึกโครงสร้างเมนูก่อนดำเนินการต่อ', + treeWarningSaveBlocked: 'โปรดแก้ไขคำเตือนของโครงสร้างเมนูและยืนยันเมนูหลักให้ถูกต้องก่อนบันทึก', + saveTreeOrder: 'บันทึกโครงสร้าง', menuCode: 'รหัสเมนู', menuName: 'ชื่อเมนู', + parentMenu: 'เมนูหลัก', + rootMenu: 'เมนูราก', componentKey: 'รหัสคอมโพเนนต์', iconKey: 'รหัสไอคอน', + unknownRoute: 'เส้นทางไม่รู้จัก', + unknownRouteHint: 'เส้นทางนี้ยังไม่ถูกรู้จักโดยหน้าเว็บ อาจเปิดไปยังหน้าที่ยังไม่พร้อม', + permissionLoadFailed: 'โหลดรายการสิทธิ์ไม่สำเร็จ จึงยังเลือกสิทธิ์และบันทึกการตั้งค่าเมนูไม่ได้', + moveUp: 'เลื่อนขึ้น', + moveDown: 'เลื่อนลง', columns: { menu: 'เมนู', route: 'เส้นทาง', diff --git a/client/src/i18n/locales/zh-CN.ts b/client/src/i18n/locales/zh-CN.ts index 58946f3..7b5dd80 100644 --- a/client/src/i18n/locales/zh-CN.ts +++ b/client/src/i18n/locales/zh-CN.ts @@ -56,6 +56,9 @@ export default { nextPage: '下一页', pageStatus: '第 {current} / {total} 页', pageSize: '每页', + refresh: '刷新', + saving: '保存中', + cancel: '取消', }, notFound: { title: '页面不可用', @@ -130,16 +133,33 @@ export default { }, menus: { title: '菜单筛选', + managementTitle: '菜单管理', + managementHint: '维护侧边栏菜单的父子层级、排序、可见性和权限入口。', createTitle: '新增菜单', editTitle: '编辑菜单', tableTitle: '菜单数据', totalSuffix: '个菜单', keywordPlaceholder: '菜单代码 / 名称 / 路由', menuStatus: '菜单状态', + visibleOnly: '只看可见菜单', + treeWarningTitle: '菜单树存在异常配置', + treeWarningHint: '后端已返回菜单树,但以下配置需要管理员确认。', + treeTitle: '菜单树', + treeDirty: '树结构有未保存调整', + treeDirtyActionBlocked: '请先保存树结构后再继续操作。', + treeWarningSaveBlocked: '请先处理菜单树异常,确认父级关系正确后再保存树结构。', + saveTreeOrder: '保存树结构', menuCode: '菜单代码', menuName: '菜单名称', + parentMenu: '上级菜单', + rootMenu: '根菜单', componentKey: '组件标识', iconKey: '图标标识', + unknownRoute: '未知路由', + unknownRouteHint: '当前路由未被前端识别,菜单可能会进入未接入页面。', + permissionLoadFailed: '权限列表加载失败,权限选择和菜单配置保存暂不可用。', + moveUp: '上移', + moveDown: '下移', columns: { menu: '菜单', route: '路由', diff --git a/client/src/services/systemAdminService.ts b/client/src/services/systemAdminService.ts index efbf5e5..1d5048a 100644 --- a/client/src/services/systemAdminService.ts +++ b/client/src/services/systemAdminService.ts @@ -10,6 +10,8 @@ import type { AdminMenuCreateRequest, AdminMenuListFilters, AdminMenuResult, + AdminMenuTreeOrderUpdateRequest, + AdminMenuTreeResult, AdminMenuUpdateRequest, AdminPermissionResult, AdminRoleCreateRequest, @@ -118,6 +120,10 @@ export async function fetchAdminMenus( return getJson>(withQuery('/api/admin/menus', filters)) } +export async function fetchAdminMenuTree(): Promise { + return getJson('/api/admin/menus/tree') +} + export async function createAdminMenu(request: AdminMenuCreateRequest): Promise { return sendJson('/api/admin/menus', 'POST', request) } @@ -126,6 +132,12 @@ export async function updateAdminMenu(menuId: string, request: AdminMenuUpdateRe return sendJson(`/api/admin/menus/${encodeURIComponent(menuId)}`, 'PUT', request) } +export async function updateAdminMenuTreeOrder( + request: AdminMenuTreeOrderUpdateRequest, +): Promise { + return sendJson('/api/admin/menus/tree-order', 'PUT', request) +} + export async function fetchAdminHotels( filters: AdminHotelListFilters = {}, ): Promise> { diff --git a/client/src/tests/systemAdminService.spec.ts b/client/src/tests/systemAdminService.spec.ts index 0804f53..a0381e4 100644 --- a/client/src/tests/systemAdminService.spec.ts +++ b/client/src/tests/systemAdminService.spec.ts @@ -10,6 +10,7 @@ import { createAdminUser, fetchAdminAudits, fetchAdminHotels, + fetchAdminMenuTree, fetchAdminMenus, fetchAdminPermissions, fetchAdminRoleDetail, @@ -20,6 +21,7 @@ import { updateAdminHotel, updateAdminHotelStatus, updateAdminMenu, + updateAdminMenuTreeOrder, updateAdminRole, updateAdminUser, } from '@/services/systemAdminService' @@ -107,6 +109,69 @@ describe('systemAdminService', () => { ) }) + it('fetches and saves the admin menu tree endpoints', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( + mockJsonResponse({ + items: [ + { + id: '40001', + parent_id: null, + menu_code: 'SYSTEM_SETTINGS', + menu_name: '系统设置', + menu_type: 'GROUP', + route_path: '/system', + component_key: 'system', + icon_key: 'pi pi-cog', + permission_code: 'SYSTEM_ADMIN_CONSOLE_ACCESS', + sort_order: 900, + visible: true, + menu_status: 'ACTIVE', + known_route: true, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + children: [], + }, + ], + warnings: ['菜单 49999 的 parent_id 不存在,已按根级异常节点返回。'], + }), + ) + + const tree = await fetchAdminMenuTree() + await updateAdminMenuTreeOrder({ + items: [ + { + menu_id: '40002', + parent_id: '40001', + sort_order: 100, + }, + ], + }) + + expect(tree.items[0]?.children).toEqual([]) + expect(tree.warnings[0]).toContain('parent_id') + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/admin/menus/tree', + expect.objectContaining({ method: 'GET' }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, + '/api/admin/menus/tree-order', + expect.objectContaining({ + method: 'PUT', + body: JSON.stringify({ + items: [ + { + menu_id: '40002', + parent_id: '40001', + sort_order: 100, + }, + ], + }), + }), + ) + }) + it('fetches the readonly permission list', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue( mockJsonResponse([ diff --git a/client/src/tests/systemMenusView.spec.ts b/client/src/tests/systemMenusView.spec.ts new file mode 100644 index 0000000..1d7b196 --- /dev/null +++ b/client/src/tests/systemMenusView.spec.ts @@ -0,0 +1,248 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createI18n } from 'vue-i18n' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import zhCN from '@/i18n/locales/zh-CN' +import SystemMenusView from '@/views/system/SystemMenusView.vue' + +vi.mock('@/services/systemAdminService', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createAdminMenu: vi.fn(), + fetchAdminMenuTree: vi.fn(), + fetchAdminPermissions: vi.fn(), + updateAdminMenu: vi.fn(), + updateAdminMenuTreeOrder: vi.fn(), + } +}) + +const systemAdminService = await import('@/services/systemAdminService') + +function createMenuTree(options: { warnings?: string[] } = {}) { + return { + items: [ + { + id: '40001', + parent_id: null, + menu_code: 'SYSTEM_SETTINGS', + menu_name: '系统设置', + menu_type: 'GROUP', + route_path: '/system', + component_key: 'system', + icon_key: 'pi pi-cog', + permission_code: 'SYSTEM_ADMIN_CONSOLE_ACCESS', + sort_order: 900, + visible: true, + menu_status: 'ACTIVE', + known_route: true, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + children: [ + { + id: '40002', + parent_id: '40001', + menu_code: 'SYSTEM_USERS', + menu_name: '用户管理', + menu_type: 'PAGE', + route_path: '/system/users', + component_key: 'systemUsers', + icon_key: 'pi pi-users', + permission_code: 'SYSTEM_USER_MANAGE', + sort_order: 100, + visible: true, + menu_status: 'ACTIVE', + known_route: true, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + children: [], + }, + { + id: '40003', + parent_id: '40001', + menu_code: 'SYSTEM_ROLES', + menu_name: '角色权限', + menu_type: 'PAGE', + route_path: '/system/roles', + component_key: 'systemRoles', + icon_key: 'pi pi-shield', + permission_code: 'SYSTEM_ROLE_MANAGE', + sort_order: 200, + visible: true, + menu_status: 'ACTIVE', + known_route: true, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + children: [], + }, + ], + }, + { + id: '49999', + parent_id: '48888', + menu_code: 'BROKEN_MENU', + menu_name: '孤儿菜单', + menu_type: 'PAGE', + route_path: '/ghost', + component_key: 'ghost', + icon_key: 'pi pi-question', + permission_code: null, + sort_order: 999, + visible: true, + menu_status: 'ACTIVE', + known_route: false, + created_at: '2026-07-16T00:00:00Z', + updated_at: '2026-07-16T00:00:00Z', + children: [], + }, + ], + warnings: options.warnings ?? [], + } +} + +function mountSystemMenusView() { + const i18n = createI18n({ + legacy: false, + locale: 'zh-CN', + messages: { + 'zh-CN': zhCN, + }, + }) + + return mount(SystemMenusView, { + global: { + plugins: [i18n], + }, + }) +} + +describe('SystemMenusView', () => { + beforeEach(() => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockReset() + vi.mocked(systemAdminService.fetchAdminPermissions).mockReset() + vi.mocked(systemAdminService.createAdminMenu).mockReset() + vi.mocked(systemAdminService.updateAdminMenu).mockReset() + vi.mocked(systemAdminService.updateAdminMenuTreeOrder).mockReset() + vi.mocked(systemAdminService.fetchAdminPermissions).mockResolvedValue([]) + }) + + it('renders the backend menu tree with warning state', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue( + createMenuTree({ + warnings: ['菜单 49999 的 parent_id=48888 不存在,已按根级异常节点返回。'], + }), + ) + + const wrapper = mountSystemMenusView() + await flushPromises() + + expect(systemAdminService.fetchAdminMenuTree).toHaveBeenCalledTimes(1) + expect(wrapper.text()).toContain('系统设置') + expect(wrapper.text()).toContain('用户管理') + expect(wrapper.text()).toContain('孤儿菜单') + expect(wrapper.text()).toContain('菜单树存在异常配置') + expect(wrapper.find('[data-testid="menu-tree"]').exists()).toBe(true) + }) + + it('saves reordered sibling nodes through tree-order endpoint', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue(createMenuTree()) + vi.mocked(systemAdminService.updateAdminMenuTreeOrder).mockResolvedValue({ + items: createMenuTree().items, + warnings: [], + }) + + const wrapper = mountSystemMenusView() + await flushPromises() + + const rolesMoveUp = wrapper.find('[data-testid="move-up-40003"]') + expect(rolesMoveUp.exists()).toBe(true) + await rolesMoveUp.trigger('click') + expect(wrapper.text()).toContain('树结构有未保存调整') + + await wrapper.find('[data-testid="save-tree-order"]').trigger('click') + await flushPromises() + + expect(systemAdminService.updateAdminMenuTreeOrder).toHaveBeenCalledWith({ + items: expect.arrayContaining([ + expect.objectContaining({ + menu_id: '40003', + parent_id: '40001', + sort_order: 100, + }), + expect.objectContaining({ + menu_id: '40002', + parent_id: '40001', + sort_order: 200, + }), + ]), + }) + }) + + it('blocks tree refresh and menu config save while tree order has unsaved changes', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue(createMenuTree()) + + const wrapper = mountSystemMenusView() + await flushPromises() + + await wrapper.find('[data-testid="move-up-40003"]').trigger('click') + + expect(wrapper.text()).toContain('树结构有未保存调整') + expect(wrapper.find('[data-testid="refresh-menu-tree"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-testid="update-menu-config"]').attributes('disabled')).toBeDefined() + }) + + it('keeps the loaded tree visible when tree-order save fails', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue(createMenuTree()) + vi.mocked(systemAdminService.updateAdminMenuTreeOrder).mockRejectedValue(new Error('保存树结构失败')) + + const wrapper = mountSystemMenusView() + await flushPromises() + + await wrapper.find('[data-testid="move-up-40003"]').trigger('click') + await wrapper.find('[data-testid="save-tree-order"]').trigger('click') + await flushPromises() + + expect(wrapper.find('[data-testid="menu-tree"]').exists()).toBe(true) + expect(wrapper.text()).toContain('保存树结构失败') + expect(wrapper.text()).toContain('用户管理') + }) + + it('shows permission load failure and disables menu config submit', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue(createMenuTree()) + vi.mocked(systemAdminService.fetchAdminPermissions).mockRejectedValue(new Error('权限接口异常')) + + const wrapper = mountSystemMenusView() + await flushPromises() + + expect(wrapper.text()).toContain('权限列表加载失败') + expect(wrapper.find('[data-testid="update-menu-config"]').attributes('disabled')).toBeDefined() + }) + + it('locks tree ordering when backend returns tree warnings', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue( + createMenuTree({ + warnings: ['菜单 49999 的 parent_id=48888 不存在,已按根级异常节点返回。'], + }), + ) + + const wrapper = mountSystemMenusView() + await flushPromises() + + expect(wrapper.find('[data-testid="save-tree-order"]').attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-testid="move-up-40003"]').attributes('disabled')).toBeDefined() + expect(wrapper.text()).toContain('请先处理菜单树异常') + }) + + it('clears dragged menu state on dragend before a later tree drop', async () => { + vi.mocked(systemAdminService.fetchAdminMenuTree).mockResolvedValue(createMenuTree()) + + const wrapper = mountSystemMenusView() + await flushPromises() + + await wrapper.find('[data-testid="menu-row-40002"]').trigger('dragstart') + await wrapper.find('[data-testid="menu-row-40002"]').trigger('dragend') + await wrapper.find('[data-testid="menu-tree"]').trigger('drop') + + expect(wrapper.text()).not.toContain('树结构有未保存调整') + }) +}) diff --git a/client/src/types/systemAdmin.ts b/client/src/types/systemAdmin.ts index a6a2830..3fc7cbd 100644 --- a/client/src/types/systemAdmin.ts +++ b/client/src/types/systemAdmin.ts @@ -107,6 +107,15 @@ export interface AdminMenuResult { updated_at: string | null } +export interface AdminMenuTreeNodeResult extends AdminMenuResult { + children: AdminMenuTreeNodeResult[] +} + +export interface AdminMenuTreeResult { + items: AdminMenuTreeNodeResult[] + warnings: string[] +} + export interface AdminHotelResult { id: string hotel_id: string @@ -199,6 +208,16 @@ export interface AdminMenuUpdateRequest { menu_status: string } +export interface AdminMenuTreeOrderItemRequest { + menu_id: string + parent_id?: string | null + sort_order?: number +} + +export interface AdminMenuTreeOrderUpdateRequest { + items: AdminMenuTreeOrderItemRequest[] +} + export interface AdminHotelCreateRequest { hotel_id: string hotel_name: string diff --git a/client/src/views/system/SystemMenusView.vue b/client/src/views/system/SystemMenusView.vue index 38c494f..30b44bd 100644 --- a/client/src/views/system/SystemMenusView.vue +++ b/client/src/views/system/SystemMenusView.vue @@ -2,14 +2,35 @@
-

{{ t('systemAdmin.menus.title') }}

- +
+

+ {{ t('systemAdmin.menus.managementTitle') }} +

+

+ {{ t('systemAdmin.menus.managementHint') }} +

+
+
+ + +
+ +
-
-
-

{{ t('systemAdmin.menus.createTitle') }}

+ - -
-
-
-

{{ t('systemAdmin.menus.tableTitle') }}

-

{{ t('workbench.totalPrefix') }} {{ total }} {{ t('systemAdmin.menus.totalSuffix') }}

-
-
- -
- {{ t('common.loading') }} -
-
- {{ errorMessage }} -
-
- {{ t('common.noData') }} -
-
- - - - - - - - - - - - - - - - - - - - - - - - - -
{{ t('systemAdmin.menus.columns.menu') }}{{ t('systemAdmin.menus.columns.route') }}{{ t('systemAdmin.menus.columns.permission') }}{{ t('systemAdmin.menus.columns.visible') }}{{ t('systemAdmin.menus.columns.status') }}{{ t('systemAdmin.menus.columns.sort') }}{{ t('systemAdmin.menus.columns.knownRoute') }}{{ t('workbench.columns.operation') }}
- {{ menu.menu_name }} - {{ menu.menu_code }} - {{ menu.route_path || '-' }}{{ menu.permission_code || '-' }}{{ menu.visible ? t('systemAdmin.common.yes') : t('systemAdmin.common.no') }}{{ statusLabel(menu.menu_status) }}{{ menu.sort_order ?? '-' }}{{ menu.known_route ? t('systemAdmin.common.yes') : t('systemAdmin.common.no') }} - -
-
- -
- -
- - {{ t('common.pageStatus', { current: currentPage, total: totalPages }) }} - -
-
+ {{ warning }} + + +

+ {{ t('systemAdmin.menus.treeWarningSaveBlocked') }} +

-
-

{{ t('systemAdmin.menus.editTitle') }}

- {{ selectedMenu.menu_code }} -
-
- - - - - - - - - -
+ {{ operationError }} +

+

+ {{ t('systemAdmin.menus.permissionLoadFailed') }} +

+ +
+