import type { Locator, Page } from '@playwright/test' import { captureScreenshot, expect, test } from './fixtures' const loginContext = { defaultRoleCode: 'admin', roles: [ { code: 'administrative', label: '行政', accountLabel: '教学系领导账号', organizationMode: 'SINGLE', organizationLabels: ['教学系'], }, { code: 'teacher', label: '教员', accountLabel: '教员账号', organizationMode: 'CASCADE', organizationLabels: ['教学系', '教研室'], }, { code: 'student', label: '学员', accountLabel: '学号或账号', organizationMode: 'NONE', organizationLabels: [], }, { code: 'admin', label: '管理员', accountLabel: '管理员账号', organizationMode: 'NONE', organizationLabels: [], }, ], departments: [ { id: '100', code: 'EQUIPMENT', name: '工程装备系', parentId: null, children: [ { id: '110', code: 'MAINTENANCE', name: '维修教研室', parentId: '100', children: [ { id: '111', code: 'MAINTENANCE-GROUP-1', name: '维修一组', parentId: '110', children: [ { id: '112', code: 'HYDROPOWER-A', name: '水电组A', parentId: '111', children: [], }, ], }, ], }, { id: '120', code: 'SUPPORT', name: '智能保障教研室', parentId: '100', children: [], }, ], }, { id: '200', code: 'COMMAND', name: '指挥系', parentId: null, children: [ { id: '210', code: 'COMMAND-TEACHING', name: '指挥教研室', parentId: '200', children: [], }, ], }, ], } as const const loginIdentities = { administrative: [ { id: '301', displayName: '秦主任', departmentId: '100', departmentName: '工程装备系' }, { id: '302', displayName: '周主任', departmentId: '200', departmentName: '指挥系' }, ], teacher: [ { id: '401', displayName: '许教员', departmentId: '100', departmentName: '工程装备系' }, { id: '402', displayName: '杜晴', departmentId: '110', departmentName: '维修教研室' }, { id: '403', displayName: '马雅柔', departmentId: '112', departmentName: '水电组A' }, { id: '404', displayName: '韩教员', departmentId: '120', departmentName: '智能保障教研室' }, ], student: [ { id: '501', displayName: '学生甲', departmentId: '112', departmentName: '水电组A' }, ], } as const const descendantDepartmentIds: Record> = { '100': new Set(['100', '110', '111', '112', '120']), '110': new Set(['110', '111', '112']), '120': new Set(['120']), '200': new Set(['200', '210']), '210': new Set(['210']), } const mockLoginContext = async (page: Page) => { await page.route('**/api/auth/v1/auth/login-context', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: '成功', data: loginContext, timestamp: '2026-08-11T00:00:00+08:00', requestId: 'ute2e-login-context-success', }), }) }) await page.route('**/api/auth/v1/auth/login-identities**', async (route) => { const url = new URL(route.request().url()) const roleCode = url.searchParams.get('roleCode') const departmentId = url.searchParams.get('departmentId') const source = roleCode && roleCode in loginIdentities ? loginIdentities[roleCode as keyof typeof loginIdentities] : [] const allowedDepartmentIds = departmentId ? descendantDepartmentIds[departmentId] : null const identities = source.filter((identity) => !allowedDepartmentIds || allowedDepartmentIds.has(identity.departmentId)) await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: '成功', data: identities, timestamp: '2026-08-11T00:00:00+08:00', requestId: 'ute2e-login-identities-success', }), }) }) } const mockLoginContextFailure = async (page: Page) => { await page.route('**/api/auth/v1/auth/login-context', async (route) => { await route.fulfill({ status: 503, contentType: 'application/json', body: JSON.stringify({ code: 50300, message: '认证配置暂不可用', data: null, timestamp: '2026-08-11T00:00:00+08:00', requestId: 'ute2e-login-context-failure', }), }) }) } const openLoginWithContext = async (page: Page) => { await mockLoginContext(page) await page.goto('/login') const contextState = page.getByText('身份认证服务正常', { exact: true }) await expect(contextState).toBeAttached() await expect(contextState).toHaveAttribute('aria-live', 'polite') await expect(contextState).toHaveCSS('position', 'absolute') await expect(contextState).toHaveCSS('clip-path', 'inset(50%)') } const adminUsernameInput = (page: Page) => page.locator('input[name="username"]') const passwordInput = (page: Page) => page.locator('input[name="password"]') const submitButton = (page: Page) => page.getByRole('button', { name: '登录系统' }) const identitySelect = (page: Page) => page.getByTestId('login-identity') const openElementSelect = async (page: Page, select: Locator) => { const combobox = select.getByRole('combobox') await select.locator('.el-select__wrapper').click() await expect(combobox).toHaveAttribute('aria-expanded', 'true') const listboxId = await combobox.getAttribute('aria-controls') expect(listboxId).toBeTruthy() const listbox = page.locator(`[id="${listboxId}"]`) await expect(listbox).toBeVisible() return listbox } const selectElementOption = async (page: Page, select: Locator, name: string | RegExp) => { const listbox = await openElementSelect(page, select) await listbox.getByRole('option', { name, exact: typeof name === 'string' }).click() } const expectElementSelectOptions = async (page: Page, select: Locator, expected: string[]) => { const listbox = await openElementSelect(page, select) const labels = (await listbox.getByRole('option').allTextContents()) .map((label) => label.replace(/\s+/g, ' ').trim()) expect(labels).toEqual(expected) await page.keyboard.press('Escape') } const expectElementSelectPlaceholder = async (select: Locator, placeholder: string) => { await expect(select.locator('.el-select__placeholder').first()).toContainText(placeholder) } test.describe('登录上下文 UI 契约', () => { test('恢复上次成功登录的部门和账号但不保存密码', async ({ page }, testInfo) => { await page.addInitScript(() => { window.localStorage.setItem('unreal-tran:web:login-identity-preference:v1', JSON.stringify({ lastRoleCode: 'teacher', selections: { teacher: { departmentId: '110', userId: '402', username: '' }, }, })) }) await openLoginWithContext(page) await expect(page.locator('[data-role-code="teacher"]')).toHaveAttribute('aria-pressed', 'true') await expect(page.getByTestId('login-organization')).toContainText('工程装备系') await expect(page.getByTestId('login-department')).toContainText('维修教研室') await expect(identitySelect(page)).toContainText('杜晴') await expect(passwordInput(page)).toHaveValue('') await expect(submitButton(page)).toBeDisabled() await captureScreenshot(page, testInfo, 'restored-login-identity-without-password') }) test('按登录身份分别恢复记住的账号和密码', async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem('unreal-tran:web:login-identity-preference:v1', JSON.stringify({ lastRoleCode: 'teacher', selections: { teacher: { departmentId: '110', userId: '402', username: '', password: 'Teacher-Remembered-1', remember: true, }, admin: { departmentId: '', userId: '', username: 'admin.remembered', password: 'Admin-Remembered-2', remember: true, }, }, })) }) await openLoginWithContext(page) await expect(identitySelect(page)).toContainText('杜晴') await expect(passwordInput(page)).toHaveValue('Teacher-Remembered-1') await expect(page.locator('input[name="rememberMe"]')).toBeChecked() await page.locator('[data-role-code="admin"]').click() await expect(adminUsernameInput(page)).toHaveValue('admin.remembered') await expect(passwordInput(page)).toHaveValue('Admin-Remembered-2') await expect(page.locator('input[name="rememberMe"]')).toBeChecked() await page.locator('[data-role-code="teacher"]').click() await expect(identitySelect(page)).toContainText('杜晴') await expect(passwordInput(page)).toHaveValue('Teacher-Remembered-1') }) test('成功登录后按勾选状态保存账号密码', async ({ page }) => { await page.route('**/api/auth/v1/auth/login', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: '成功', data: { accessToken: 'remember-access-token', refreshToken: 'remember-refresh-token', expiresIn: 3600 }, }), })) await page.route('**/api/auth/v1/auth/me', (route) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: '成功', data: { user: { id: '1', username: 'admin.saved', displayName: '系统管理员', mustChangePassword: true }, activeRoleId: '1', roles: [{ id: '1', code: 'admin', name: '管理员', enabled: true }], permissions: [], authorizationMode: 'SINGLE_ACTIVE', }, }), })) await openLoginWithContext(page) await adminUsernameInput(page).fill('admin.saved') await passwordInput(page).fill('Saved-Password-3') await page.locator('input[name="rememberMe"]').check() await submitButton(page).click() await expect.poll(() => page.evaluate(() => { const raw = window.localStorage.getItem('unreal-tran:web:login-identity-preference:v1') || '{}' return JSON.parse(raw).selections?.admin })).toEqual({ departmentId: '', userId: '', username: 'admin.saved', password: 'Saved-Password-3', remember: true, }) }) test('已删除的部门或账号不会被本地偏好错误回填', async ({ page }) => { await page.addInitScript(() => { window.localStorage.setItem('unreal-tran:web:login-identity-preference:v1', JSON.stringify({ lastRoleCode: 'teacher', selections: { teacher: { departmentId: '999', userId: '999', username: '' }, }, })) }) await openLoginWithContext(page) await expect(page.locator('[data-role-code="teacher"]')).toHaveAttribute('aria-pressed', 'true') await expectElementSelectPlaceholder(page.getByTestId('login-organization'), '请选择教学系') await expect(page.getByTestId('login-department')).toBeDisabled() await expect(identitySelect(page)).toBeDisabled() await expect(passwordInput(page)).toHaveValue('') }) test('默认管理员且四个身份展示各自约定字段', async ({ page }, testInfo) => { await openLoginWithContext(page) const roleButtons = page.locator('[data-role-code]') await expect(roleButtons).toHaveCount(4) expect(await roleButtons.evaluateAll((buttons) => buttons.map((button) => ({ code: button.getAttribute('data-role-code'), label: button.textContent?.trim(), })))).toEqual([ { code: 'administrative', label: '行政' }, { code: 'teacher', label: '教员' }, { code: 'student', label: '学员' }, { code: 'admin', label: '管理员' }, ]) await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true') await expect(adminUsernameInput(page)).toHaveAttribute('placeholder', '请输入管理员账号') await expect(identitySelect(page)).toHaveCount(0) await expect(page.getByTestId('login-organization')).toHaveCount(0) await expect(page.getByTestId('login-department')).toHaveCount(0) await page.locator('[data-role-code="administrative"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expect(identitySelect(page)).toBeVisible() await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号') await expect(identitySelect(page)).toBeDisabled() await expect(page.getByTestId('login-organization')).toHaveCount(0) await expect(page.getByTestId('login-department')).toBeVisible() await page.locator('[data-role-code="teacher"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expect(identitySelect(page)).toBeVisible() await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号') await expect(identitySelect(page)).toBeDisabled() await expect(page.getByTestId('login-organization')).toBeVisible() await expect(page.getByTestId('login-department')).toBeVisible() await page.locator('[data-role-code="student"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expect(identitySelect(page)).toBeVisible() await expectElementSelectPlaceholder(identitySelect(page), '请选择学员账号') await expect(identitySelect(page)).toBeEnabled() await expect(page.getByTestId('login-organization')).toHaveCount(0) await expect(page.getByTestId('login-department')).toHaveCount(0) await captureScreenshot(page, testInfo, 'four-role-fields') }) test('行政身份按 SINGLE 模式只选择一个教学系', async ({ page }, testInfo) => { await openLoginWithContext(page) await page.locator('[data-role-code="administrative"]').click() const department = page.getByTestId('login-department') await expect(department).toBeEnabled() await expectElementSelectPlaceholder(department, '请选择教学系') await expectElementSelectOptions(page, department, [ '工程装备系', '指挥系', ]) await expect(submitButton(page)).toBeDisabled() await selectElementOption(page, department, '工程装备系') await expect(identitySelect(page)).toBeEnabled() await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号') await selectElementOption(page, identitySelect(page), /秦主任.*工程装备系/) await passwordInput(page).fill('ContractOnly-NotSubmitted') await expect(submitButton(page)).toBeEnabled() await captureScreenshot(page, testInfo, 'administrative-single') }) test('教员身份按 CASCADE 模式支持教学系本级和直属教研室', async ({ page }, testInfo) => { await openLoginWithContext(page) await page.locator('[data-role-code="teacher"]').click() const organization = page.getByTestId('login-organization') const department = page.getByTestId('login-department') await expect(organization).toBeEnabled() await expect(department).toBeDisabled() await expect(identitySelect(page)).toBeDisabled() await expect(submitButton(page)).toBeDisabled() await selectElementOption(page, organization, '工程装备系') await expect(department).toBeEnabled() await expectElementSelectPlaceholder(department, '请选择教研室') await expectElementSelectOptions(page, department, [ '工程装备系(本级)', '维修教研室', '智能保障教研室', ]) await selectElementOption(page, department, '工程装备系(本级)') await expect(department).toContainText('工程装备系(本级)') await expect(identitySelect(page)).toBeEnabled() await selectElementOption(page, identitySelect(page), /许教员.*工程装备系/) await passwordInput(page).fill('ContractOnly-NotSubmitted') await expect(submitButton(page)).toBeEnabled() await selectElementOption(page, department, '维修教研室') await expect(department).toContainText('维修教研室') await expect(passwordInput(page)).toHaveValue('') await expect(identitySelect(page)).toBeEnabled() const identityListbox = await openElementSelect(page, identitySelect(page)) await expect(identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ })).toBeVisible() await identityListbox.getByRole('option', { name: /马雅柔.*水电组A/ }).click() await passwordInput(page).fill('ContractOnly-NotSubmitted') await expect(submitButton(page)).toBeEnabled() await captureScreenshot(page, testInfo, 'teacher-cascade-current-and-child') }) test('切换身份会清空账号密码和已选组织', async ({ page }, testInfo) => { await openLoginWithContext(page) await page.locator('[data-role-code="teacher"]').click() await selectElementOption(page, page.getByTestId('login-organization'), '工程装备系') await selectElementOption(page, page.getByTestId('login-department'), '维修教研室') await selectElementOption(page, identitySelect(page), /马雅柔.*水电组A/) await passwordInput(page).fill('ContractOnly-NotSubmitted') await page.locator('[data-role-code="administrative"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expectElementSelectPlaceholder(identitySelect(page), '请选择行政账号') await expect(passwordInput(page)).toHaveValue('') await expect(page.getByTestId('login-organization')).toHaveCount(0) await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教学系') await selectElementOption(page, page.getByTestId('login-department'), '指挥系') await selectElementOption(page, identitySelect(page), /周主任.*指挥系/) await passwordInput(page).fill('ContractOnly-NotSubmitted') await page.locator('[data-role-code="teacher"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expectElementSelectPlaceholder(identitySelect(page), '请选择教员账号') await expect(passwordInput(page)).toHaveValue('') await expectElementSelectPlaceholder(page.getByTestId('login-organization'), '请选择教学系') await expectElementSelectPlaceholder(page.getByTestId('login-department'), '请选择教研室') await expect(page.getByTestId('login-department')).toBeDisabled() await captureScreenshot(page, testInfo, 'role-switch-clears-sensitive-fields') }) test('登录页只按当前身份和组织加载安全账号候选且不会预填密码', async ({ page }) => { await openLoginWithContext(page) await expect(adminUsernameInput(page)).toHaveValue('') await expect(passwordInput(page)).toHaveValue('') await expect(adminUsernameInput(page)).toHaveAttribute('autocomplete', 'username') await expect(passwordInput(page)).toHaveAttribute('autocomplete', 'current-password') await expect(page.locator('.login-password-toggle')).toBeVisible() await expect(page.locator('.login-password-toggle svg')).toHaveCount(1) await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '显示密码') await expect(page.locator('[data-password-visibility-icon="hidden"]')).toBeVisible() await page.locator('.login-password-toggle').click() await expect(passwordInput(page)).toHaveAttribute('type', 'text') await expect(page.locator('.login-password-toggle')).toHaveAttribute('aria-label', '隐藏密码') await expect(page.locator('[data-password-visibility-icon="visible"]')).toBeVisible() await page.locator('.login-password-toggle').click() await expect(passwordInput(page)).toHaveAttribute('type', 'password') await expect(page.locator('[data-password-visibility-icon="hidden"]')).toBeVisible() expect(await adminUsernameInput(page).getAttribute('list')).toBeNull() await expect(page.locator('datalist')).toHaveCount(0) await expect(page.locator('select[name*="user" i], select[name*="account" i]')).toHaveCount(0) await page.locator('[data-role-code="administrative"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expect(identitySelect(page)).toBeDisabled() await expect(passwordInput(page)).toHaveValue('') await page.locator('[data-role-code="teacher"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expect(identitySelect(page)).toBeDisabled() await expect(passwordInput(page)).toHaveValue('') await page.locator('[data-role-code="student"]').click() await expect(adminUsernameInput(page)).toHaveCount(0) await expect(identitySelect(page)).toBeEnabled() const studentListbox = await openElementSelect(page, identitySelect(page)) await expect(studentListbox.getByRole('option', { name: /学生甲.*水电组A/ })).toBeVisible() await page.keyboard.press('Escape') await expect(passwordInput(page)).toHaveValue('') await page.locator('[data-role-code="admin"]').click() await expect(identitySelect(page)).toHaveCount(0) await expect(adminUsernameInput(page)).toHaveValue('') await expect(passwordInput(page)).toHaveValue('') }) test('登录上下文失败时管理员账号密码入口仍可使用', async ({ page }, testInfo) => { await mockLoginContextFailure(page) await page.goto('/login') const contextState = page.getByText('认证配置连接失败', { exact: true }) await expect(contextState).toBeAttached() await expect(contextState).toHaveAttribute('aria-live', 'polite') await expect(contextState).toBeVisible() await expect(page.locator('[data-role-code="admin"]')).toHaveAttribute('aria-pressed', 'true') await expect(page.getByTestId('login-organization')).toHaveCount(0) await expect(page.getByTestId('login-department')).toHaveCount(0) await adminUsernameInput(page).fill('admin.contract') await passwordInput(page).fill('ContractOnly-NotSubmitted') await expect(submitButton(page)).toBeEnabled() await captureScreenshot(page, testInfo, 'context-failure-admin-available') }) }) for (const viewport of [ { width: 375, height: 812 }, { width: 1024, height: 768 }, { width: 1440, height: 900 }, ]) { test(`登录页在 ${viewport.width}px 宽度无横向滚动`, async ({ page }, testInfo) => { await page.setViewportSize(viewport) await openLoginWithContext(page) const geometry = await page.evaluate(() => { const card = document.querySelector('.login-card')?.getBoundingClientRect() return { documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, bodyOverflow: document.body.scrollWidth - document.body.clientWidth, cardLeft: card?.left ?? -1, cardRight: card?.right ?? Number.POSITIVE_INFINITY, viewportWidth: window.innerWidth, } }) expect(geometry.documentOverflow).toBeLessThanOrEqual(1) expect(geometry.bodyOverflow).toBeLessThanOrEqual(1) expect(geometry.cardLeft).toBeGreaterThanOrEqual(0) expect(geometry.cardRight).toBeLessThanOrEqual(geometry.viewportWidth + 1) await captureScreenshot(page, testInfo, `login-responsive-${viewport.width}`) }) }