import { test as base, expect, type Page, type BrowserContext } from '@playwright/test' /** * 管理端测试 Fixture * 提供 Mock 数据、登录状态注入、API 路由拦截 */ // ==================== 常量配置 ==================== /** 测试租户编码 */ export const TENANT_CODE = 'gdlib' /** 测试用户信息 */ export const MOCK_USER = { id: 1, username: 'admin', nickname: '测试管理员', phone: '13800000001', email: 'admin@test.com', avatar: null, tenantId: 2, tenantCode: TENANT_CODE, tenantName: '广东省立中山图书馆', roles: ['tenant_admin'], permissions: [ 'contest:create', 'contest:read', 'contest:update', 'contest:delete', 'contest:publish', 'contest:registration:read', 'contest:work:read', 'registration:read', 'registration:update', 'judge:read', 'judge:create', 'judge:update', 'judge:delete', 'review:read', 'review:score', 'user:read', 'user:create', 'user:update', 'user:delete', 'menu:read', 'homework:read', 'activity:read', ], } /** Mock JWT Token */ export const MOCK_TOKEN = 'mock-jwt-token-for-e2e-testing-' + Date.now() /** Mock 菜单数据(模拟后端返回的菜单树) */ export const MOCK_MENUS = [ { id: 100, name: '工作台', path: '/workbench/dashboard', icon: 'DashboardOutlined', component: 'workbench/TenantDashboard', sort: 1, children: undefined, }, { id: 200, name: '活动管理', path: null, icon: 'TrophyOutlined', component: null, sort: 2, children: [ { id: 201, name: '活动列表', path: '/contests/list', icon: 'UnorderedListOutlined', component: 'contests/Index', sort: 1, }, { id: 202, name: '报名管理', path: '/contests/registrations', icon: 'FormOutlined', component: 'contests/registrations/Index', sort: 2, }, { id: 203, name: '作品管理', path: '/contests/works', icon: 'FileTextOutlined', component: 'contests/works/Index', sort: 3, }, { id: 204, name: '评委管理', path: '/contests/judges', icon: 'SolutionOutlined', component: 'contests/judges/Index', sort: 4, }, { id: 205, name: '评审规则', path: '/contests/reviews', icon: 'AuditOutlined', component: 'contests/reviews/Index', sort: 5, }, ], }, { id: 300, name: '用户管理', path: '/system/users', icon: 'TeamOutlined', component: 'system/users/Index', sort: 3, children: undefined, }, ] // ==================== Mock API 响应数据 ==================== /** 仪表盘统计 Mock */ export const MOCK_DASHBOARD = { totalContests: 5, ongoingContests: 2, totalRegistrations: 128, pendingRegistrations: 12, totalWorks: 96, todayRegistrations: 8, tenant: { id: 2, name: '广东省立中山图书馆', tenantType: 'library', }, recentContests: [ { id: 1, contestName: '少儿绘本创作大赛', startTime: '2026-03-01T00:00:00Z', endTime: '2026-06-30T23:59:59Z', status: 'ongoing', _count: { registrations: 45, works: 32 }, }, { id: 2, contestName: '春季阅读推广活动', startTime: '2026-04-01T00:00:00Z', endTime: '2026-05-31T23:59:59Z', status: 'ongoing', _count: { registrations: 83, works: 64 }, }, ], } /** 活动列表 Mock */ export const MOCK_CONTESTS = { list: [ { id: 1, contestName: '少儿绘本创作大赛', contestType: 'individual', stage: 'registering', contestState: 'published', startTime: '2026-03-01T00:00:00Z', endTime: '2026-06-30T23:59:59Z', _count: { registrations: 45, works: 32, judges: 5 }, reviewedCount: 20, totalWorksCount: 32, }, { id: 2, contestName: '春季阅读推广活动', contestType: 'team', stage: 'submitting', contestState: 'published', startTime: '2026-04-01T00:00:00Z', endTime: '2026-05-31T23:59:59Z', _count: { registrations: 83, works: 64, judges: 3 }, reviewedCount: 0, totalWorksCount: 64, }, { id: 3, contestName: '环保主题绘画比赛', contestType: 'individual', stage: 'unpublished', contestState: 'unpublished', startTime: '2026-05-01T00:00:00Z', endTime: '2026-08-31T23:59:59Z', _count: { registrations: 0, works: 0, judges: 0 }, reviewedCount: 0, totalWorksCount: 0, }, ], total: 3, page: 1, pageSize: 10, } /** 活动统计 Mock */ export const MOCK_CONTEST_STATS = { total: 3, unpublished: 1, registering: 1, submitting: 1, reviewing: 0, finished: 0, } /** 报名列表 Mock */ export const MOCK_REGISTRATIONS = { list: [ { id: 1, contestId: 1, contestName: '少儿绘本创作大赛', participantName: '张小明', participantType: 'individual', status: 'approved', createdAt: '2026-03-15T10:30:00Z', phone: '138****0001', }, { id: 2, contestId: 1, contestName: '少儿绘本创作大赛', participantName: '李小红', participantType: 'individual', status: 'pending', createdAt: '2026-03-16T14:20:00Z', phone: '139****0002', }, { id: 3, contestId: 2, contestName: '春季阅读推广活动', participantName: '创意小队', participantType: 'team', status: 'approved', createdAt: '2026-04-02T09:00:00Z', phone: '137****0003', }, ], total: 3, page: 1, pageSize: 10, } /** 作品列表 Mock */ export const MOCK_WORKS = { list: [ { id: 1, title: '我的梦想家园', contestId: 1, contestName: '少儿绘本创作大赛', authorName: '张小明', status: 'submitted', submittedAt: '2026-03-20T15:00:00Z', coverUrl: 'https://via.placeholder.com/200', }, { id: 2, title: '森林探险记', contestId: 1, contestName: '少儿绘本创作大赛', authorName: '李小红', status: 'reviewing', submittedAt: '2026-03-22T10:30:00Z', coverUrl: 'https://via.placeholder.com/200', }, ], total: 2, page: 1, pageSize: 10, } /** 评审规则 Mock */ export const MOCK_REVIEW_RULES = { list: [ { id: 1, name: '标准评审规则', description: '适用于一般绘本创作活动的评审标准', scoreDimensions: [ { name: '创意性', weight: 30, maxScore: 100 }, { name: '绘画技巧', weight: 30, maxScore: 100 }, { name: '故事性', weight: 25, maxScore: 100 }, { name: '完整性', weight: 15, maxScore: 100 }, ], calculationMethod: 'average', createdAt: '2026-01-15T00:00:00Z', }, ], total: 1, page: 1, pageSize: 10, } /** 用户列表 Mock */ export const MOCK_USERS = { list: [ { id: 1, username: 'admin', nickname: '测试管理员', phone: '13800000001', email: 'admin@test.com', status: 1, userType: 'tenant_admin', createdAt: '2026-01-01T00:00:00Z', roles: [{ id: 1, name: '租户管理员', code: 'tenant_admin' }], }, { id: 2, username: 'worker01', nickname: '工作人员A', phone: '13800000002', email: 'worker@test.com', status: 1, userType: 'tenant_staff', createdAt: '2026-02-01T00:00:00Z', roles: [{ id: 2, name: '工作人员', code: 'tenant_staff' }], }, { id: 3, username: 'judge01', nickname: '评委老师A', phone: '13800000003', email: 'judge@test.com', status: 0, userType: 'judge', createdAt: '2026-02-15T00:00:00Z', roles: [{ id: 3, name: '评委', code: 'judge' }], }, ], total: 3, page: 1, pageSize: 10, } // ==================== Fixture 类型定义 ==================== type AdminFixtures = { /** 已注入登录态的管理端页面 */ adminPage: Page } // ==================== 辅助函数 ==================== /** * 设置所有需要的 API Mock 路由 */ export async function setupApiMocks(page: Page): Promise { // 登录接口 await page.route('**/api/auth/login', async (route) => { const request = route.request() const postData = request.postDataJSON() if (!postData?.username || !postData?.password) { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 400, message: '用户名和密码不能为空', data: null, timestamp: Date.now(), path: '/api/auth/login' }), }) return } if (postData.username === 'wrong') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 401, message: '用户名或密码错误', data: null, timestamp: Date.now(), path: '/api/auth/login' }), }) return } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { token: MOCK_TOKEN, user: MOCK_USER }, timestamp: Date.now(), path: '/api/auth/login', }), }) }) // 获取用户信息 await page.route('**/api/auth/user-info', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USER, timestamp: Date.now(), path: '/api/auth/user-info' }), }) }) // 登出 await page.route('**/api/auth/logout', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: null, timestamp: Date.now(), path: '/api/auth/logout' }), }) }) // 获取用户菜单 await page.route('**/api/menus/user-menus', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_MENUS, timestamp: Date.now(), path: '/api/menus/user-menus' }), }) }) // 仪表盘数据 await page.route('**/api/contests/dashboard', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_DASHBOARD, timestamp: Date.now(), path: '/api/contests/dashboard' }), }) }) // 活动列表 await page.route('**/api/contests?**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_CONTESTS, timestamp: Date.now(), path: '/api/contests' }), }) }) // 活动统计 await page.route('**/api/contests/stats', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_CONTEST_STATS, timestamp: Date.now(), path: '/api/contests/stats' }), }) }) // 活动详情 await page.route('**/api/contests/*', async (route) => { const url = route.request().url() if (url.includes('/stats') || url.includes('/dashboard') || url.includes('/registrations') || url.includes('/works') || url.includes('/reviews')) { await route.fallback() return } await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { ...MOCK_CONTESTS.list[0], organizers: '广东省立中山图书馆', contestTenants: [2] }, timestamp: Date.now(), path: '/api/contests/1', }), }) }) // 创建活动 await page.route('**/api/contests', async (route) => { if (route.request().method() === 'POST') { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { id: 10 }, timestamp: Date.now(), path: '/api/contests' }), }) } else { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_CONTESTS, timestamp: Date.now(), path: '/api/contests' }), }) } }) // 报名列表 await page.route('**/api/contests/registrations**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_REGISTRATIONS, timestamp: Date.now(), path: '/api/contests/registrations' }), }) }) // 作品列表 await page.route('**/api/contests/works**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_WORKS, timestamp: Date.now(), path: '/api/contests/works' }), }) }) // 评审规则列表 await page.route('**/api/contests/reviews/rules**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_REVIEW_RULES, timestamp: Date.now(), path: '/api/contests/reviews/rules' }), }) }) // 用户列表 await page.route('**/api/users**', async (route) => { if (route.request().method() === 'POST') { // 创建用户 await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { id: 100 }, timestamp: Date.now(), path: '/api/users' }), }) } else { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }), }) } }) // 租户信息 await page.route('**/api/tenants/my-tenant', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' }, timestamp: Date.now(), path: '/api/tenants/my-tenant', }), }) }) // 评审任务列表(评委端) await page.route('**/api/activities/review**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: { list: [ { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' }, ], total: 1, }, timestamp: Date.now(), }), }) }) // 评审规则下拉选项(创建活动页使用) await page.route('**/api/contests/review-rules/select**', async (route) => { await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: [ { id: 1, ruleName: '标准评审规则' }, ], timestamp: Date.now(), path: '/api/contests/review-rules/select', }), }) }) // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401) await page.route('**/api/**', async (route) => { const url = route.request().url() const method = route.request().method() // 只拦截未被更具体 mock 处理的请求 await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, message: 'success', data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 }, timestamp: Date.now(), path: new URL(url).pathname, }), }) }) } /** * 注入登录态到浏览器 * 通过设置 Cookie 模拟已登录状态 */ export async function injectAuthState(page: Page): Promise { // 先访问页面以便能设置 Cookie await page.goto('/p/login') // 注入 Cookie(与 setToken 函数一致,path 为 '/') await page.evaluate((token) => { document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}` }, MOCK_TOKEN) } /** * 导航到管理端页面(已注入登录态后) * 等待路由守卫完成和页面渲染 */ export async function navigateToAdmin(page: Page, path: string = ''): Promise { const targetUrl = `/${TENANT_CODE}${path}` await page.goto(targetUrl) // 等待页面基本加载完成(BasicLayout 渲染) await page.waitForSelector('.layout, .login-container', { timeout: 15_000 }) } /** * 等待 Ant Design 表格加载完成 */ export async function waitForTable(page: Page): Promise { await page.waitForSelector('.ant-table', { timeout: 10_000 }) // 等待表格数据加载 await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 }) } // ==================== 组件预热 ==================== /** 标记是否已完成组件预热(Vite 编译缓存只需触发一次) */ let componentsWarmedUp = false /** * 预热管理端页面组件 * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败 */ async function warmupComponents(page: Page): Promise { if (componentsWarmedUp) return try { // 展开活动管理子菜单 const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first() await submenu.click() await page.waitForTimeout(500) // 点击活动列表触发组件加载 await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click() await page.waitForSelector('.contests-page', { timeout: 15_000 }) // 导航回工作台 await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click() await page.waitForTimeout(500) componentsWarmedUp = true } catch { // 预热失败不影响测试(可能组件已被缓存) } } // ==================== 扩展 Fixture ==================== export const test = base.extend({ adminPage: async ({ page }, use) => { // 设置 API Mock await setupApiMocks(page) // 注入登录态 await injectAuthState(page) // 导航到管理端首页 await navigateToAdmin(page) // 等待侧边栏加载 await page.waitForSelector('.custom-sider', { timeout: 15_000 }) // 预热组件(首次运行时触发 Vite 编译) await warmupComponents(page) await use(page) }, }) export { expect }