实现系统菜单树管理第一版

This commit is contained in:
andy
2026-07-16 12:47:21 +07:00
parent 5390b8f71b
commit ed37d5f955
9 changed files with 1410 additions and 316 deletions

View File

@@ -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',

View File

@@ -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: 'เส้นทาง',

View File

@@ -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: '路由',

View File

@@ -10,6 +10,8 @@ import type {
AdminMenuCreateRequest,
AdminMenuListFilters,
AdminMenuResult,
AdminMenuTreeOrderUpdateRequest,
AdminMenuTreeResult,
AdminMenuUpdateRequest,
AdminPermissionResult,
AdminRoleCreateRequest,
@@ -118,6 +120,10 @@ export async function fetchAdminMenus(
return getJson<PlatformPageResult<AdminMenuResult>>(withQuery('/api/admin/menus', filters))
}
export async function fetchAdminMenuTree(): Promise<AdminMenuTreeResult> {
return getJson<AdminMenuTreeResult>('/api/admin/menus/tree')
}
export async function createAdminMenu(request: AdminMenuCreateRequest): Promise<AdminMenuResult> {
return sendJson<AdminMenuResult>('/api/admin/menus', 'POST', request)
}
@@ -126,6 +132,12 @@ export async function updateAdminMenu(menuId: string, request: AdminMenuUpdateRe
return sendJson<AdminMenuResult>(`/api/admin/menus/${encodeURIComponent(menuId)}`, 'PUT', request)
}
export async function updateAdminMenuTreeOrder(
request: AdminMenuTreeOrderUpdateRequest,
): Promise<AdminMenuTreeResult> {
return sendJson<AdminMenuTreeResult>('/api/admin/menus/tree-order', 'PUT', request)
}
export async function fetchAdminHotels(
filters: AdminHotelListFilters = {},
): Promise<PlatformPageResult<AdminHotelResult>> {

View File

@@ -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([

View File

@@ -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<typeof import('@/services/systemAdminService')>()
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('树结构有未保存调整')
})
})

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -90,6 +90,194 @@
gap: 18px;
}
.system-admin-menu-grid {
display: grid;
grid-template-columns: minmax(360px, 0.95fr) minmax(360px, 1.05fr);
gap: 18px;
align-items: start;
}
.section-actions,
.form-actions,
.menu-tree-actions {
display: flex;
align-items: center;
gap: 10px;
}
.section-actions,
.form-actions {
justify-content: flex-end;
}
.filter-grid .checkbox-field {
min-height: 38px;
display: flex;
align-items: center;
gap: 9px;
align-self: end;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 8px 10px;
}
.filter-grid .checkbox-field input {
min-height: 0;
width: 16px;
height: 16px;
padding: 0;
}
.menu-warning-panel {
display: grid;
gap: 12px;
border-color: rgba(245, 158, 11, 0.35);
background: #fffbeb;
}
.menu-warning-panel ul {
display: grid;
gap: 8px;
margin: 0;
padding: 0 0 0 18px;
color: #92400e;
font-size: 13px;
font-weight: 700;
}
.menu-operation-panel {
display: grid;
gap: 8px;
border-color: rgba(245, 158, 11, 0.28);
background: var(--th-color-white);
}
.menu-tree-section,
.menu-detail-panel {
align-self: start;
overflow: hidden;
}
.menu-tree {
display: grid;
gap: 8px;
padding: 14px 20px 20px;
}
.menu-tree-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto auto auto;
align-items: center;
gap: 10px;
min-height: 54px;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
padding: 8px 10px;
transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
}
.menu-tree-row:hover {
border-color: rgba(37, 99, 235, 0.36);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.07);
transform: translateY(-1px);
}
.menu-tree-row--selected {
border-color: var(--th-color-blue-600);
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.18);
}
.menu-tree-row--muted {
background: var(--th-color-slate-50);
}
.menu-tree-row--warning {
border-color: rgba(245, 158, 11, 0.45);
}
.menu-tree-main {
min-width: 0;
display: grid;
grid-template-columns: auto minmax(0, max-content) minmax(80px, 1fr);
align-items: center;
gap: 8px;
border: 0;
background: transparent;
color: inherit;
cursor: pointer;
padding: 0;
text-align: left;
}
.menu-tree-branch {
width: 9px;
height: 9px;
border: 2px solid var(--th-color-blue-600);
border-radius: 50%;
background: var(--th-color-blue-50);
}
.menu-tree-name {
overflow: hidden;
color: var(--th-color-slate-900);
font-size: 13px;
font-weight: 900;
text-overflow: ellipsis;
white-space: nowrap;
}
.menu-tree-meta {
color: var(--th-color-slate-500);
font-size: 12px;
font-weight: 800;
}
.status-pill--active {
background: #dcfce7;
color: #15803d;
}
.code-chip--warning {
background: #ffedd5;
color: #c2410c;
}
.icon-button {
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
border: 1px solid var(--th-color-slate-200);
border-radius: var(--th-radius-sm);
background: var(--th-color-white);
color: var(--th-color-slate-700);
cursor: pointer;
}
.icon-button:hover:not(:disabled) {
border-color: var(--th-color-blue-600);
color: var(--th-color-blue-600);
}
.icon-button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.menu-config-form {
padding: 16px 20px 20px;
}
.form-message--warning {
color: #b45309;
}
.form-message--error {
color: var(--th-color-danger);
}
.text-button,
.row-action {
border: 0;
@@ -102,6 +290,12 @@
text-decoration: none;
}
.text-button:disabled,
.row-action:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.empty-state {
min-height: 220px;
display: grid;
@@ -288,7 +482,8 @@ td small {
}
@media (max-width: 980px) {
.system-admin-grid {
.system-admin-grid,
.system-admin-menu-grid {
grid-template-columns: 1fr;
}
}
@@ -306,4 +501,18 @@ td small {
align-items: flex-start;
flex-direction: column;
}
.section-actions,
.form-actions {
align-items: stretch;
flex-direction: column;
}
.menu-tree-row {
grid-template-columns: 1fr;
}
.menu-tree-actions {
justify-content: flex-start;
}
}