import type { Page } from '@playwright/test' import { captureScreenshot, expect, test } from './fixtures' import { adminCredentials, confirmMessageBox, loginAsAdmin } from './helpers' const missingProjectId = '9223372036854775807' const contentPages = [ { path: '/content/models', childPath: `/content/models/${missingProjectId}/edit`, label: '模型制作' }, { path: '/content/scenes', childPath: `/content/scenes/${missingProjectId}/edit`, label: '场景制作' }, { path: '/content/training-projects', childPath: `/content/training-projects/${missingProjectId}/edit`, label: '训练编排' }, { path: '/content/ofd', childPath: `/content/ofd/${missingProjectId}/preview`, label: '作业指导' }, ] as const const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const menuRow = (page: Page, name: string) => page .locator('.menu-table-panel .el-table__body-wrapper .el-table__row') .filter({ has: page.locator('.menu-name-cell b').filter({ hasText: new RegExp(`^${escapeRegExp(name)}$`) }), }) .first() const waitForMenuManagement = async (page: Page) => { await expect(page.locator('.menus-page')).toBeVisible() await expect(page.locator('.menu-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 15_000 }) await expect(page.locator('.menu-table-panel .el-table__body-wrapper .el-table__row').first()).toBeVisible({ timeout: 15_000 }) } const expectActiveContentMenu = async (page: Page, label: string, href: string) => { await expect(page.locator('.header-navigation .header-nav-trigger.active')).toContainText('内容制作') const activeItem = page.locator('.secondary-navigation a.active') await expect(activeItem).toContainText(label) await expect(activeItem).toHaveAttribute('href', href) } const waitForContentList = async (page: Page) => { await expect(page.locator('.content-projects-page .content-table-panel')).toBeVisible() await expect(page.locator('.content-table-panel .el-loading-mask:visible')).toHaveCount(0, { timeout: 15_000 }) } const positionText = (row: ReturnType) => row .locator('.cell') .filter({ hasText: /^\d+\s*\/\s*\d+$/ }) .first() test('菜单草稿操作不落库且新版内容导航与子路由活动态正确', async ({ page }, testInfo) => { await loginAsAdmin(page) await test.step('管理员进入菜单管理并验证搜索与重置', async () => { await page.goto('/system/menus') await expect(page).toHaveURL(/\/system\/menus(?:\?|$)/) // 页头卡已去掉,页面身份改由页面标签栏承担 await expect(page.getByRole('tab', { name: '菜单管理', exact: true })).toBeVisible() await waitForMenuManagement(page) await expect(page.locator('.secondary-navigation a.active')).toContainText('菜单管理') const toolbar = page.locator('.menu-toolbar') const query = toolbar.getByPlaceholder('请输入菜单名称、权限码或说明') await query.fill('模型制作') await toolbar.getByRole('button', { name: '搜索', exact: true }).click() await expect(menuRow(page, '模型制作')).toBeVisible() await expect(page.locator('.sorting-note')).toBeVisible() await captureScreenshot(page, testInfo, 'menu-search-model') await toolbar.getByRole('button', { name: '重置', exact: true }).click() await expect(query).toHaveValue('') await expect(page.locator('.sorting-note')).toHaveCount(0) await expect(menuRow(page, '场景制作')).toBeVisible() }) const menuMutationRequests: string[] = [] page.on('request', (request) => { if (/\/menus(?:\/|\?|$)/.test(request.url()) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) { menuMutationRequests.push(`${request.method()} ${request.url()}`) } }) await test.step('三种菜单布局预览均可切换', async () => { await page.getByRole('button', { name: '布局预览', exact: true }).click() const drawer = page.locator('.menu-preview-drawer:visible, .el-drawer:visible').last() const preview = drawer.locator('.menu-layout-preview') await expect(drawer).toBeVisible() const modes = [ { label: '顶部一级 + 左侧二级', value: 'top-side', screenshot: 'menu-preview-top-side' }, { label: '左侧分组', value: 'side', screenshot: 'menu-preview-side' }, { label: '顶部下拉', value: 'top', screenshot: 'menu-preview-top' }, ] as const for (const mode of modes) { await drawer.locator('.el-radio-button').filter({ hasText: mode.label }).click() await expect(preview).toHaveAttribute('data-mode', mode.value) if (mode.value === 'top-side') { await expect(preview.locator(':scope > header')).toBeVisible() await expect(preview.locator(':scope > aside')).toBeVisible() await expect(preview.locator('.preview-dropdown')).toHaveCount(0) } else if (mode.value === 'side') { await expect(preview.locator(':scope > header')).toHaveCount(0) await expect(preview.locator(':scope > aside')).toBeVisible() await expect(preview.locator('.preview-tree-group').first()).toBeVisible() } else { await expect(preview.locator(':scope > header')).toBeVisible() await expect(preview.locator(':scope > aside')).toHaveCount(0) await expect(preview.locator('.preview-dropdown')).toBeVisible() } await captureScreenshot(page, testInfo, mode.screenshot) } await page.keyboard.press('Escape') await expect(drawer).toBeHidden() }) await test.step('编辑显示名称只形成草稿并可完整撤销', async () => { const originalName = '模型制作' const temporaryName = '模型制作临时草稿' const row = menuRow(page, originalName) await row.getByRole('button', { name: '编辑', exact: true }).click() const drawer = page.locator('.el-drawer:visible').last() await expect(drawer).toContainText('编辑菜单显示信息') await drawer.getByPlaceholder('请输入 2–16 个字符的菜单名称').fill(temporaryName) await drawer.getByRole('button', { name: '应用到草稿', exact: true }).click() await expect(drawer).toBeHidden() await expect(menuRow(page, temporaryName)).toBeVisible() await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeEnabled() await captureScreenshot(page, testInfo, 'menu-edit-draft') await page.getByRole('button', { name: '撤销草稿', exact: true }).click() await confirmMessageBox(page, '撤销修改') await expect(menuRow(page, originalName)).toBeVisible() await expect(menuRow(page, temporaryName)).toHaveCount(0) await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeDisabled() }) await test.step('同级排序只形成草稿并可完整撤销', async () => { const row = menuRow(page, '模型制作') const originalPosition = (await positionText(row).innerText()).trim() const match = originalPosition.match(/^(\d+)\s*\/\s*(\d+)$/) expect(match, `无法识别菜单当前位置:${originalPosition}`).not.toBeNull() const current = Number(match?.[1]) const total = Number(match?.[2]) expect(total).toBeGreaterThan(1) const moveLabel = current < total ? '下移' : '上移' const expectedPosition = `${current < total ? current + 1 : current - 1} / ${total}` await row.getByRole('button', { name: moveLabel, exact: true }).click() await expect(positionText(menuRow(page, '模型制作'))).toHaveText(expectedPosition) await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeEnabled() await captureScreenshot(page, testInfo, 'menu-reorder-draft') await page.getByRole('button', { name: '撤销草稿', exact: true }).click() await confirmMessageBox(page, '撤销修改') await expect(positionText(menuRow(page, '模型制作'))).toHaveText(originalPosition) await expect(page.getByRole('button', { name: '撤销草稿', exact: true })).toBeDisabled() }) await test.step('四类内容新名称、训练模式与列表活动态正确', async () => { for (const item of contentPages) { await page.goto(item.path) await expect(page).toHaveURL(new RegExp(`${item.path.replaceAll('/', '\\/')}(?:\\?|$)`)) // 内容制作页面已去掉 SystemPageHeader,页面身份改由页面标签栏承担 await expect(page.getByRole('tab', { name: item.label, exact: true })).toBeVisible() await waitForContentList(page) await expectActiveContentMenu(page, item.label, item.path) if (item.path === '/content/training-projects') { const modeTabs = page.locator('.training-mode-tabs button') await expect(modeTabs).toHaveCount(3) await expect(modeTabs.filter({ hasText: '虚拟仿真' })).toBeVisible() await expect(modeTabs.filter({ hasText: '实装实训' })).toBeVisible() await expect(modeTabs.filter({ hasText: '对抗训练' })).toBeVisible() await expect(modeTabs.filter({ hasText: '虚拟仿真' })).toHaveAttribute('aria-pressed', 'true') await captureScreenshot(page, testInfo, 'content-training-three-modes') } if (item.path === '/content/ofd') { await expect(page.locator('.content-table-panel')).toBeVisible() await captureScreenshot(page, testInfo, 'content-guide-list-or-empty') } } }) await test.step('内容编辑、预览子路由保持对应菜单活动态', async () => { for (const item of contentPages) { await page.goto(item.childPath) await expect(page).toHaveURL(new RegExp(`${item.childPath.replaceAll('/', '\\/')}(?:\\?|$)`)) await expect(page.locator('.content-editor-page, .guide-workflow-page')).toBeVisible() await expectActiveContentMenu(page, item.label, item.path) } await captureScreenshot(page, testInfo, 'content-child-route-active') }) expect(menuMutationRequests, '草稿编辑、排序、预览与撤销不应调用菜单写接口').toEqual([]) }) interface ApiMenuNode { id: string name: string description: string dataVersion: number children: ApiMenuNode[] } const configurationPayload = (desired: ApiMenuNode[], versionSource: ApiMenuNode[]) => { const versions = new Map(versionSource.flatMap((group) => [group, ...group.children]).map((node) => [String(node.id), node.dataVersion])) return { groups: desired.map((group) => ({ id: String(group.id), name: group.name, description: group.description, version: versions.get(String(group.id)), items: group.children.map((item) => ({ id: String(item.id), name: item.name, description: item.description, version: versions.get(String(item.id)), })), })), } } test('菜单配置可原子保存并恢复原始快照', async ({ request }) => { const credentials = adminCredentials() const loginResponse = await request.post('/api/auth/v1/auth/login', { data: { username: credentials.username, password: credentials.password, roleCode: 'admin', rememberMe: false }, }) expect(loginResponse.ok()).toBeTruthy() const loginBody = await loginResponse.json() as { data?: { accessToken?: string } } const accessToken = loginBody.data?.accessToken expect(accessToken).toBeTruthy() const headers = { Authorization: `Bearer ${accessToken}` } const readTree = async () => { const response = await request.get('/api/auth/v1/menus/tree', { headers }) expect(response.ok()).toBeTruthy() const body = await response.json() as { data?: ApiMenuNode[] } return body.data ?? [] } try { const baseline = await readTree() expect(baseline).toHaveLength(5) const draft = structuredClone(baseline) const contentGroup = draft.find((group) => group.children.some((item) => item.name === '模型制作')) const modelItem = contentGroup?.children.find((item) => item.name === '模型制作') expect(modelItem).toBeTruthy() modelItem!.name = '模型制作验收' let changed = false try { const saveResponse = await request.put('/api/auth/v1/menus/configuration', { headers, data: configurationPayload(draft, baseline), }) expect(saveResponse.ok()).toBeTruthy() changed = true const saved = await readTree() expect(saved.flatMap((group) => group.children).some((item) => item.name === '模型制作验收')).toBeTruthy() } finally { if (changed) { const current = await readTree() const restoreResponse = await request.put('/api/auth/v1/menus/configuration', { headers, data: configurationPayload(baseline, current), }) expect(restoreResponse.ok()).toBeTruthy() const restored = await readTree() expect(restored.flatMap((group) => group.children).some((item) => item.name === '模型制作')).toBeTruthy() expect(restored.flatMap((group) => group.children).some((item) => item.name === '模型制作验收')).toBeFalsy() } } } finally { await request.post('/api/auth/v1/auth/logout', { headers }) } })