library-picturebook-activity/lesingle-creation-frontend/e2e/fixtures/admin.fixture.ts
En 98e9ad1d28 feat(前端): 测试环境登录框支持自动填充测试账号
通过 VITE_AUTO_FILL_TEST 环境变量控制,在 .env.test 中启用,
使测试环境构建后登录框也能自动填充测试账号,方便测试人员使用。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-11 17:03:22 +08:00

672 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<void> {
// 登录接口
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<void> {
// 先访问页面以便能设置 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<void> {
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<void> {
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<void> {
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<AdminFixtures>({
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 }