library-picturebook-activity/frontend/e2e/fixtures/admin.fixture.ts

672 lines
19 KiB
TypeScript
Raw Normal View History

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 }