feat: 管理端全功能 E2E 测试——40 用例覆盖登录、仪表盘、活动、报名、作品、评审、用户、导航

新增 10 个管理端 E2E 测试文件和 1 个 Mock fixture:
- admin.fixture.ts: Mock 数据 + 登录注入 + 组件预热 + 兜底 API 拦截
- login/contests/dashboard/navigation/registrations/works/reviews/users 等 9 个 spec

关键修复:route.fallback() 替代 route.continue() 修正 Mock 链式传递;
review-rules/select Mock + 兜底拦截器防止未 mock 请求到达真实后端。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
En 2026-04-09 12:52:39 +08:00
parent f7f97c64e4
commit f03991819d
177 changed files with 17285 additions and 796 deletions

View File

@ -0,0 +1,34 @@
package com.competition.common.annotation;
import java.lang.annotation.*;
import java.util.concurrent.TimeUnit;
/**
* 接口速率限制注解
* 用于公开接口防止恶意调用
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {
/**
* 时间窗口内允许的最大请求次数
*/
int permits() default 10;
/**
* 时间窗口大小
*/
long duration() default 1;
/**
* 时间单位
*/
TimeUnit timeUnit() default TimeUnit.SECONDS;
/**
* 限制维度ip / user
*/
String key() default "ip";
}

View File

@ -1,26 +1,42 @@
package com.competition.common.config; package com.competition.common.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.CorsFilter;
import java.util.List;
/** /**
* 跨域配置 * 跨域配置
* 从配置文件注入允许的域名列表避免使用通配符 * 导致的安全风险
*/ */
@Slf4j
@Configuration @Configuration
public class CorsConfig { public class CorsConfig {
@Value("${cors.allowed-origins:http://localhost:3000,http://localhost:5173}")
private List<String> allowedOrigins;
@Bean @Bean
public CorsFilter corsFilter() { public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration(); CorsConfiguration config = new CorsConfiguration();
config.setAllowCredentials(true); config.setAllowCredentials(true);
config.addAllowedOriginPattern("*");
// 使用配置的域名列表替代通配符 *
for (String origin : allowedOrigins) {
config.addAllowedOriginPattern(origin.trim());
}
config.addAllowedHeader("*"); config.addAllowedHeader("*");
config.addAllowedMethod("*"); config.addAllowedMethod("*");
config.addExposedHeader("X-Trace-Id"); config.addExposedHeader("X-Trace-Id");
log.info("CORS 允许的域名:{}", allowedOrigins);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config); source.registerCorsConfiguration("/**", config);
return new CorsFilter(source); return new CorsFilter(source);

View File

@ -1,5 +1,6 @@
package com.competition.common.config; package com.competition.common.config;
import com.competition.common.interceptor.RateLimitInterceptor;
import com.competition.common.interceptor.TraceIdInterceptor; import com.competition.common.interceptor.TraceIdInterceptor;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -14,9 +15,13 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebMvcConfig implements WebMvcConfigurer { public class WebMvcConfig implements WebMvcConfigurer {
private final TraceIdInterceptor traceIdInterceptor; private final TraceIdInterceptor traceIdInterceptor;
private final RateLimitInterceptor rateLimitInterceptor;
@Override @Override
public void addInterceptors(InterceptorRegistry registry) { public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**"); registry.addInterceptor(traceIdInterceptor).addPathPatterns("/**");
// 速率限制拦截器仅对公开接口生效
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/public/**", "/webhook/**");
} }
} }

View File

@ -15,4 +15,7 @@ public final class CacheConstants {
/** 认证缓存天数 */ /** 认证缓存天数 */
public static final int AUTH_CACHE_DAYS = 7; public static final int AUTH_CACHE_DAYS = 7;
/** Token 黑名单 key 前缀(用于登出/密码修改后使旧 Token 失效) */
public static final String TOKEN_BLACKLIST_PREFIX = "token:blacklist:";
} }

View File

@ -38,15 +38,17 @@ public abstract class BaseEntity implements Serializable {
@TableField(value = "deleted", fill = FieldFill.INSERT) @TableField(value = "deleted", fill = FieldFill.INSERT)
private Integer deleted; private Integer deleted;
// ====== 旧审计字段过渡期保留 ====== // ====== 旧审计字段过渡期保留请使用 createBy/updateBy ======
/** 创建人 ID */ /** 创建人 ID已弃用请使用 createBy */
@Schema(description = "创建人ID") @Deprecated
@Schema(description = "创建人ID已弃用请使用 createBy")
@TableField(value = "creator", fill = FieldFill.INSERT) @TableField(value = "creator", fill = FieldFill.INSERT)
private Integer creator; private Integer creator;
/** 修改人 ID */ /** 修改人 ID已弃用请使用 updateBy */
@Schema(description = "修改人ID") @Deprecated
@Schema(description = "修改人ID已弃用请使用 updateBy")
@TableField(value = "modifier", fill = FieldFill.INSERT_UPDATE) @TableField(value = "modifier", fill = FieldFill.INSERT_UPDATE)
private Integer modifier; private Integer modifier;

View File

@ -5,18 +5,53 @@ import lombok.Getter;
/** /**
* 错误码枚举 * 错误码枚举
* HTTP 状态码级别 + 业务错误码分组
* 10xx 用户模块 / 20xx 活动模块 / 30xx 评审模块 / 40xx 作品模块 / 50xx 系统模块
*/ */
@Getter @Getter
@AllArgsConstructor @AllArgsConstructor
public enum ErrorCode { public enum ErrorCode {
// ====== HTTP 状态码级别 ======
SUCCESS(200, "success"), SUCCESS(200, "success"),
BAD_REQUEST(400, "请求参数错误"), BAD_REQUEST(400, "请求参数错误"),
UNAUTHORIZED(401, "未登录或 Token 已过期"), UNAUTHORIZED(401, "未登录或 Token 已过期"),
FORBIDDEN(403, "没有访问权限"), FORBIDDEN(403, "没有访问权限"),
NOT_FOUND(404, "资源不存在"), NOT_FOUND(404, "资源不存在"),
CONFLICT(409, "数据冲突"), CONFLICT(409, "数据冲突"),
INTERNAL_ERROR(500, "系统内部错误"); INTERNAL_ERROR(500, "系统内部错误"),
// ====== 用户模块 10xx ======
USER_NOT_FOUND(1001, "用户不存在"),
USER_DISABLED(1002, "用户已被禁用"),
USER_PASSWORD_ERROR(1003, "密码错误"),
USER_DUPLICATE(1004, "用户名已存在"),
USER_PHONE_DUPLICATE(1005, "手机号已注册"),
// ====== 活动模块 20xx ======
CONTEST_NOT_FOUND(2001, "活动不存在"),
CONTEST_ALREADY_PUBLISHED(2002, "活动已发布"),
CONTEST_NOT_PUBLISHED(2003, "活动未发布"),
CONTEST_TIME_INVALID(2004, "活动时间配置无效"),
CONTEST_REGISTRATION_CLOSED(2005, "报名已截止"),
CONTEST_SUBMIT_CLOSED(2006, "提交已截止"),
CONTEST_ALREADY_FINISHED(2007, "活动已结束"),
CONTEST_REVIEW_INCOMPLETE(2008, "评审未完成"),
// ====== 评审模块 30xx ======
REVIEW_NOT_FOUND(3001, "评审记录不存在"),
REVIEW_ALREADY_SCORED(3002, "已评分,请勿重复提交"),
REVIEW_FINAL_SCORE_LOCKED(3003, "终分已锁定,无法修改评分"),
REVIEW_NOT_ASSIGNED(3004, "作品未分配给该评委"),
// ====== 作品模块 40xx ======
WORK_NOT_FOUND(4001, "作品不存在"),
WORK_ALREADY_SUBMITTED(4002, "作品已提交"),
WORK_RESUBMIT_NOT_ALLOWED(4003, "不允许重新提交"),
// ====== 文件上传模块 50xx ======
FILE_TYPE_NOT_ALLOWED(5001, "不支持的文件类型"),
FILE_SIZE_EXCEEDED(5002, "文件大小超限");
private final Integer code; private final Integer code;
private final String message; private final String message;

View File

@ -0,0 +1,101 @@
package com.competition.common.interceptor;
import com.competition.common.annotation.RateLimit;
import com.competition.common.result.Result;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.concurrent.TimeUnit;
/**
* 速率限制拦截器
* 基于 Redis 实现滑动窗口限流
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class RateLimitInterceptor implements HandlerInterceptor {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;
private static final String RATE_LIMIT_PREFIX = "rate_limit:";
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
RateLimit rateLimit = handlerMethod.getMethodAnnotation(RateLimit.class);
if (rateLimit == null) {
return true;
}
String key = buildKey(request, rateLimit);
String redisKey = RATE_LIMIT_PREFIX + key;
// 获取当前计数
String countStr = redisTemplate.opsForValue().get(redisKey);
long currentCount = countStr != null ? Long.parseLong(countStr) : 0;
if (currentCount >= rateLimit.permits()) {
log.warn("接口速率限制触发key={},已请求 {} 次,限制 {} 次", key, currentCount, rateLimit.permits());
response.setContentType("application/json;charset=UTF-8");
response.setStatus(429);
response.getWriter().write(objectMapper.writeValueAsString(
Result.error(429, "请求过于频繁,请稍后再试")));
return false;
}
// 增加计数
Long newCount = redisTemplate.opsForValue().increment(redisKey);
if (newCount != null && newCount == 1) {
// 首次请求设置过期时间
redisTemplate.expire(redisKey, rateLimit.duration(), rateLimit.timeUnit());
}
return true;
}
/**
* 构建限流 key
*/
private String buildKey(HttpServletRequest request, RateLimit rateLimit) {
String identity;
if ("user".equals(rateLimit.key())) {
// 基于 User-ID需要认证后才有
identity = request.getHeader("X-User-Id");
if (identity == null) identity = request.getRemoteAddr();
} else {
// 基于 IP
identity = getClientIp(request);
}
return request.getMethod() + ":" + request.getRequestURI() + ":" + identity;
}
/**
* 获取客户端真实 IP
*/
private String getClientIp(HttpServletRequest request) {
String ip = request.getHeader("X-Forwarded-For");
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("X-Real-IP");
}
if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理取第一个
if (ip != null && ip.contains(",")) {
ip = ip.split(",")[0].trim();
}
return ip;
}
}

View File

@ -0,0 +1,51 @@
package com.competition.common.util;
/**
* 敏感信息脱敏工具类
* 用于日志输出时对手机号身份证号Token 等进行脱敏处理
*/
public final class SensitiveUtil {
private SensitiveUtil() {}
/**
* 手机号脱敏保留前3位和后4位
* 13812345678 138****5678
*/
public static String phone(String phone) {
if (phone == null || phone.length() < 7) {
return "***";
}
return phone.substring(0, 3) + "****" + phone.substring(phone.length() - 4);
}
/**
* Token 脱敏只显示前8位和后4位
* eyJhbGciOi...xyz eyJhbGci...xyz
*/
public static String token(String token) {
if (token == null || token.length() < 12) {
return "***";
}
return token.substring(0, 8) + "..." + token.substring(token.length() - 4);
}
/**
* 身份证号脱敏保留前3位和后4位
*/
public static String idCard(String idCard) {
if (idCard == null || idCard.length() < 7) {
return "***";
}
return idCard.substring(0, 3) + "***********" + idCard.substring(idCard.length() - 4);
}
/**
* 通用脱敏只显示前后各 n
*/
public static String mask(String value, int keepChars) {
if (value == null) return "***";
if (value.length() <= keepChars * 2) return "***";
return value.substring(0, keepChars) + "***" + value.substring(value.length() - keepChars);
}
}

View File

@ -66,7 +66,7 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
return LocalDateTime.parse(dateTime, SPACE_FORMATTER); return LocalDateTime.parse(dateTime, SPACE_FORMATTER);
} catch (Exception ex) { } catch (Exception ex) {
log.warn("日期格式解析失败:{}", dateTime, ex); log.warn("日期格式解析失败:{}", dateTime, ex);
return null; throw new BusinessException(ErrorCode.BAD_REQUEST, "日期格式无效:" + dateTime + ",请使用 yyyy-MM-ddTHH:mm:ss 或 yyyy-MM-dd HH:mm:ss 格式");
} }
} }
} }
@ -303,6 +303,11 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在"); throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
} }
// 已发布的活动限制编辑需先撤回
if (PublishStatus.PUBLISHED.getValue().equals(entity.getContestState())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "活动已发布,请先撤回后再编辑");
}
mapDtoToEntity(dto, entity); mapDtoToEntity(dto, entity);
updateById(entity); updateById(entity);
@ -319,6 +324,11 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在"); throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
} }
// 发布时校验关键时间字段的完整性和合理性
if (PublishStatus.PUBLISHED.getValue().equals(contestState)) {
validateContestTimes(entity);
}
entity.setContestState(contestState); entity.setContestState(contestState);
updateById(entity); updateById(entity);
log.info("活动状态更新成功ID{},新状态:{}", id, contestState); log.info("活动状态更新成功ID{},新状态:{}", id, contestState);
@ -333,11 +343,51 @@ public class ContestServiceImpl extends ServiceImpl<ContestMapper, BizContest> i
throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在"); throw BusinessException.of(ErrorCode.NOT_FOUND, "活动不存在");
} }
// 检查是否有未完成的评审任务
LambdaQueryWrapper<BizContestWorkJudgeAssignment> pendingWrapper = new LambdaQueryWrapper<>();
pendingWrapper.eq(BizContestWorkJudgeAssignment::getContestId, id);
pendingWrapper.ne(BizContestWorkJudgeAssignment::getStatus, "completed");
long pendingCount = contestWorkJudgeAssignmentMapper.selectCount(pendingWrapper);
if (pendingCount > 0) {
throw BusinessException.of(ErrorCode.BAD_REQUEST,
String.format("还有 %d 个评审任务未完成,无法结束活动", pendingCount));
}
entity.setStatus("finished"); entity.setStatus("finished");
updateById(entity); updateById(entity);
log.info("活动已结束ID{}", id); log.info("活动已结束ID{}", id);
} }
/**
* 校验活动时间合理性
*/
private void validateContestTimes(BizContest entity) {
// 检查报名时间是否完整
if (entity.getRegisterStartTime() == null || entity.getRegisterEndTime() == null) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "请先设置完整的报名时间");
}
// 检查时间顺序报名开始 < 报名结束
if (entity.getRegisterStartTime().isAfter(entity.getRegisterEndTime())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "报名开始时间不能晚于报名结束时间");
}
// 检查提交时间
if (entity.getSubmitStartTime() != null && entity.getSubmitEndTime() != null) {
if (entity.getSubmitStartTime().isAfter(entity.getSubmitEndTime())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "提交开始时间不能晚于提交结束时间");
}
// 提交开始应该在报名结束之后或同时
if (entity.getSubmitStartTime().isBefore(entity.getRegisterEndTime())) {
log.warn("提交开始时间早于报名结束时间,允许报名与提交重叠");
}
}
// 检查评审时间
if (entity.getReviewStartTime() != null && entity.getReviewEndTime() != null) {
if (entity.getReviewStartTime().isAfter(entity.getReviewEndTime())) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "评审开始时间不能晚于评审结束时间");
}
}
}
@Override @Override
public void reopenContest(Long id) { public void reopenContest(Long id) {
log.info("重新开启活动ID{}", id); log.info("重新开启活动ID{}", id);

View File

@ -74,6 +74,12 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
throw BusinessException.of(ErrorCode.BAD_REQUEST, "报名未通过审核,无法提交作品"); throw BusinessException.of(ErrorCode.BAD_REQUEST, "报名未通过审核,无法提交作品");
} }
// 校验报名归属只有报名者本人才能提交作品
if (registration.getUserId() != null && !registration.getUserId().equals(submitterId)) {
log.warn("越权提交尝试报名用户ID={}提交者ID={}报名ID={}", registration.getUserId(), submitterId, dto.getRegistrationId());
throw BusinessException.of(ErrorCode.FORBIDDEN, "无权替他人提交作品");
}
Long contestId = registration.getContestId(); Long contestId = registration.getContestId();
// 查询活动提交规则 // 查询活动提交规则
@ -382,24 +388,44 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
Map<Long, String> contestCalculationRuleCache = new HashMap<>(); Map<Long, String> contestCalculationRuleCache = new HashMap<>();
Map<Long, Map<Long, BigDecimal>> contestWeightMapCache = new HashMap<>(); Map<Long, Map<Long, BigDecimal>> contestWeightMapCache = new HashMap<>();
// 批量预加载评审规则避免 N+1 查询
Set<Long> ruleIds = contestIds.stream()
.map(cid -> finalContestMap.get(cid))
.filter(c -> c != null && c.getReviewRuleId() != null)
.map(BizContest::getReviewRuleId)
.collect(Collectors.toSet());
Map<Long, BizContestReviewRule> ruleMap = new HashMap<>();
if (!ruleIds.isEmpty()) {
LambdaQueryWrapper<BizContestReviewRule> ruleWrapper = new LambdaQueryWrapper<>();
ruleWrapper.in(BizContestReviewRule::getId, ruleIds);
contestReviewRuleMapper.selectList(ruleWrapper)
.forEach(r -> ruleMap.put(r.getId(), r));
}
// 批量预加载所有相关活动的评委权重
LambdaQueryWrapper<BizContestJudge> allJudgesWrapper = new LambdaQueryWrapper<>();
allJudgesWrapper.in(BizContestJudge::getContestId, contestIds);
allJudgesWrapper.eq(BizContestJudge::getValidState, 1);
List<BizContestJudge> allJudges = contestJudgeMapper.selectList(allJudgesWrapper);
Map<Long, List<BizContestJudge>> judgesByContestId = allJudges.stream()
.collect(Collectors.groupingBy(BizContestJudge::getContestId));
for (Long cid : contestIds) { for (Long cid : contestIds) {
BizContest c = contestMap.get(cid); BizContest c = finalContestMap.get(cid);
if (c == null) { if (c == null) {
continue; continue;
} }
String calculationRule = "average"; String calculationRule = "average";
if (c.getReviewRuleId() != null) { if (c.getReviewRuleId() != null) {
BizContestReviewRule rule = contestReviewRuleMapper.selectById(c.getReviewRuleId()); BizContestReviewRule rule = ruleMap.get(c.getReviewRuleId());
if (rule != null && StringUtils.hasText(rule.getCalculationRule())) { if (rule != null && StringUtils.hasText(rule.getCalculationRule())) {
calculationRule = rule.getCalculationRule(); calculationRule = rule.getCalculationRule();
} }
} }
contestCalculationRuleCache.put(cid, calculationRule); contestCalculationRuleCache.put(cid, calculationRule);
LambdaQueryWrapper<BizContestJudge> judgeWrapper = new LambdaQueryWrapper<>(); List<BizContestJudge> judges = judgesByContestId.getOrDefault(cid, Collections.emptyList());
judgeWrapper.eq(BizContestJudge::getContestId, cid);
judgeWrapper.eq(BizContestJudge::getValidState, 1);
List<BizContestJudge> judges = contestJudgeMapper.selectList(judgeWrapper);
Map<Long, BigDecimal> weightMap = new HashMap<>(); Map<Long, BigDecimal> weightMap = new HashMap<>();
for (BizContestJudge j : judges) { for (BizContestJudge j : judges) {
weightMap.put(j.getJudgeId(), j.getWeight() != null ? j.getWeight() : BigDecimal.ONE); weightMap.put(j.getJudgeId(), j.getWeight() != null ? j.getWeight() : BigDecimal.ONE);
@ -659,11 +685,32 @@ public class ContestWorkServiceImpl extends ServiceImpl<ContestWorkMapper, BizCo
} }
@Override @Override
public String nextContestWorkNo(Long contestId) { public synchronized String nextContestWorkNo(Long contestId) {
// 使用 MAX(work_no) 查询避免并发编号重复
LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<BizContestWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(BizContestWork::getContestId, contestId); wrapper.eq(BizContestWork::getContestId, contestId);
long count = count(wrapper); wrapper.likeRight(BizContestWork::getWorkNo, "W" + contestId + "-");
return "W" + contestId + "-" + (count + 1); wrapper.orderByDesc(BizContestWork::getWorkNo);
wrapper.last("LIMIT 1");
BizContestWork lastWork = getOne(wrapper, false);
int nextSeq = 1;
if (lastWork != null && lastWork.getWorkNo() != null) {
try {
String no = lastWork.getWorkNo();
int dashIndex = no.lastIndexOf("-");
if (dashIndex > 0) {
nextSeq = Integer.parseInt(no.substring(dashIndex + 1)) + 1;
}
} catch (NumberFormatException e) {
log.warn("解析作品编号失败:{},将使用默认序号", lastWork.getWorkNo());
// 降级使用 count + 1
LambdaQueryWrapper<BizContestWork> countWrapper = new LambdaQueryWrapper<>();
countWrapper.eq(BizContestWork::getContestId, contestId);
nextSeq = (int) count(countWrapper) + 1;
}
}
return "W" + contestId + "-" + nextSeq;
} }
private Map<String, Object> workToMap(BizContestWork entity) { private Map<String, Object> workToMap(BizContestWork entity) {

View File

@ -138,6 +138,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> batchAssignWorks(Long contestId, List<Long> workIds, List<Long> judgeIds, Long creatorId) { public Map<String, Object> batchAssignWorks(Long contestId, List<Long> workIds, List<Long> judgeIds, Long creatorId) {
log.info("批量分配作品活动ID{},作品数:{},评委数:{}", contestId, workIds.size(), judgeIds.size()); log.info("批量分配作品活动ID{},作品数:{},评委数:{}", contestId, workIds.size(), judgeIds.size());
@ -158,6 +159,7 @@ public class ContestReviewServiceImpl implements IContestReviewService {
} }
@Override @Override
@Transactional(rollbackFor = Exception.class)
public Map<String, Object> autoAssignWorks(Long contestId, Long creatorId) { public Map<String, Object> autoAssignWorks(Long contestId, Long creatorId) {
log.info("自动分配作品活动ID{}", contestId); log.info("自动分配作品活动ID{}", contestId);
@ -294,6 +296,12 @@ public class ContestReviewServiceImpl implements IContestReviewService {
throw BusinessException.of(ErrorCode.FORBIDDEN, "无权修改此评分"); throw BusinessException.of(ErrorCode.FORBIDDEN, "无权修改此评分");
} }
// 检查作品的终分是否已计算已计算则拒绝修改
BizContestWork work = workMapper.selectById(scoreEntity.getWorkId());
if (work != null && work.getFinalScore() != null) {
throw BusinessException.of(ErrorCode.BAD_REQUEST, "终分已计算完成,无法修改评分");
}
if (dto.getDimensionScores() != null) { if (dto.getDimensionScores() != null) {
scoreEntity.setDimensionScores(dto.getDimensionScores()); scoreEntity.setDimensionScores(dto.getDimensionScores());
} }

View File

@ -95,17 +95,21 @@ public class LeaiWebhookController {
String remoteWorkId = LeaiUtil.toString(data.get("work_id"), null); String remoteWorkId = LeaiUtil.toString(data.get("work_id"), null);
// 5. V4.0 同步规则处理 // 5. V4.0 同步规则处理
boolean syncSuccess = true;
String errorMsg = null;
if (remoteWorkId != null && !remoteWorkId.isEmpty()) { if (remoteWorkId != null && !remoteWorkId.isEmpty()) {
try { try {
leaiSyncService.syncWork(remoteWorkId, data, "Webhook[" + event + "]"); leaiSyncService.syncWork(remoteWorkId, data, "Webhook[" + event + "]");
} catch (Exception e) { } catch (Exception e) {
syncSuccess = false;
errorMsg = e.getMessage();
log.error("[Webhook] 同步处理异常: remoteWorkId={}", remoteWorkId, e); log.error("[Webhook] 同步处理异常: remoteWorkId={}", remoteWorkId, e);
} }
} }
// 6. 记录事件幂等去重 // 6. 记录事件标记处理状态便于定时重试
webhookEventService.saveEvent(webhookId, webhookEvent, remoteWorkId, payload); webhookEventService.saveEvent(webhookId, webhookEvent, remoteWorkId, payload, syncSuccess, errorMsg);
return Collections.singletonMap("status", "ok"); return Collections.singletonMap("status", syncSuccess ? "ok" : "error");
} }
} }

View File

@ -41,10 +41,18 @@ public class LeaiWebhookEvent {
@TableField(value = "payload", typeHandler = JacksonTypeHandler.class) @TableField(value = "payload", typeHandler = JacksonTypeHandler.class)
private Object payload; private Object payload;
@Schema(description = "是否已处理0-未处理1-已处理") @Schema(description = "处理状态0-未处理1-已处理成功2-处理失败")
@TableField("processed") @TableField("processed")
private Integer processed; private Integer processed;
@Schema(description = "失败原因")
@TableField("error_message")
private String errorMessage;
@Schema(description = "重试次数")
@TableField("retry_count")
private Integer retryCount;
@Schema(description = "创建时间") @Schema(description = "创建时间")
@TableField("create_time") @TableField("create_time")
private LocalDateTime createTime; private LocalDateTime createTime;

View File

@ -3,6 +3,8 @@ package com.competition.modules.leai.service;
import com.baomidou.mybatisplus.extension.service.IService; import com.baomidou.mybatisplus.extension.service.IService;
import com.competition.modules.leai.entity.LeaiWebhookEvent; import com.competition.modules.leai.entity.LeaiWebhookEvent;
import java.util.List;
/** /**
* 乐读派 Webhook 事件 Service 接口 * 乐读派 Webhook 事件 Service 接口
*/ */
@ -17,12 +19,28 @@ public interface ILeaiWebhookEventService extends IService<LeaiWebhookEvent> {
boolean existsByEventId(String eventId); boolean existsByEventId(String eventId);
/** /**
* 保存 Webhook 事件记录 * 保存 Webhook 事件记录兼容旧接口默认处理成功
*/
void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload);
/**
* 保存 Webhook 事件记录含处理状态
* *
* @param eventId 事件唯一ID * @param eventId 事件唯一ID
* @param eventType 事件类型 * @param eventType 事件类型
* @param remoteWorkId 乐读派作品ID * @param remoteWorkId 乐读派作品ID
* @param payload 事件载荷 * @param payload 事件载荷
* @param success 是否处理成功
* @param errorMsg 失败原因成功时为 null
*/ */
void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload); void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload, boolean success, String errorMsg);
/**
* 查询需要重试的失败事件处理状态为失败且重试次数未达上限
*
* @param maxRetryCount 最大重试次数
* @param limit 查询条数
* @return 失败事件列表
*/
List<LeaiWebhookEvent> findFailedEvents(int maxRetryCount, int limit);
} }

View File

@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List;
/** /**
* 乐读派 Webhook 事件 Service 实现 * 乐读派 Webhook 事件 Service 实现
@ -29,13 +30,31 @@ public class LeaiWebhookEventServiceImpl extends ServiceImpl<LeaiWebhookEventMap
@Override @Override
public void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload) { public void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload) {
saveEvent(eventId, eventType, remoteWorkId, payload, true, null);
}
@Override
public void saveEvent(String eventId, String eventType, String remoteWorkId, Object payload,
boolean success, String errorMsg) {
LeaiWebhookEvent event = new LeaiWebhookEvent(); LeaiWebhookEvent event = new LeaiWebhookEvent();
event.setEventId(eventId); event.setEventId(eventId);
event.setEventType(eventType); event.setEventType(eventType);
event.setRemoteWorkId(remoteWorkId); event.setRemoteWorkId(remoteWorkId);
event.setPayload(payload); event.setPayload(payload);
event.setProcessed(1); event.setProcessed(success ? 1 : 2);
event.setErrorMessage(errorMsg);
event.setRetryCount(0);
event.setCreateTime(LocalDateTime.now()); event.setCreateTime(LocalDateTime.now());
save(event); save(event);
} }
@Override
public List<LeaiWebhookEvent> findFailedEvents(int maxRetryCount, int limit) {
LambdaQueryWrapper<LeaiWebhookEvent> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(LeaiWebhookEvent::getProcessed, 2); // 处理失败
wrapper.lt(LeaiWebhookEvent::getRetryCount, maxRetryCount);
wrapper.orderByAsc(LeaiWebhookEvent::getCreateTime);
wrapper.last("LIMIT " + limit);
return list(wrapper);
}
} }

View File

@ -1,6 +1,8 @@
package com.competition.modules.leai.task; package com.competition.modules.leai.task;
import com.competition.modules.leai.entity.LeaiWebhookEvent;
import com.competition.modules.leai.service.ILeaiSyncService; import com.competition.modules.leai.service.ILeaiSyncService;
import com.competition.modules.leai.service.ILeaiWebhookEventService;
import com.competition.modules.leai.service.LeaiApiClient; import com.competition.modules.leai.service.LeaiApiClient;
import com.competition.modules.leai.util.LeaiUtil; import com.competition.modules.leai.util.LeaiUtil;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -12,10 +14,10 @@ import java.util.List;
import java.util.Map; import java.util.Map;
/** /**
* B3 定时对账任务 * 定时任务B3 对账 + Webhook 失败重试
* 30 分钟调用 B3 接口对账补偿 Webhook 遗漏
* <p> * <p>
* 查询范围最近 2 小时内更新的作品覆盖 2 个对账周期确保不遗漏边界数据 * 1. 30 分钟调用 B3 接口对账补偿 Webhook 遗漏
* 2. 10 分钟重试失败的 Webhook 事件最多 3
*/ */
@Slf4j @Slf4j
@Component @Component
@ -24,9 +26,16 @@ public class LeaiReconcileTask {
private final LeaiApiClient leaiApiClient; private final LeaiApiClient leaiApiClient;
private final ILeaiSyncService leaiSyncService; private final ILeaiSyncService leaiSyncService;
private final ILeaiWebhookEventService webhookEventService;
/** 最大重试次数 */
private static final int MAX_RETRY_COUNT = 3;
/** 每次重试查询条数 */
private static final int RETRY_BATCH_SIZE = 20;
/** /**
* 每1分钟执行一次测试阶段正式环境改为30分钟初始延迟10秒 * B3 定时对账
* 30 分钟执行初始延迟 60
*/ */
@Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000) @Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000)
public void reconcile() { public void reconcile() {
@ -42,13 +51,21 @@ public class LeaiReconcileTask {
} }
int synced = 0; int synced = 0;
int skipped = 0;
for (Map<String, Object> work : works) { for (Map<String, Object> work : works) {
String workId = LeaiUtil.toString(work.get("workId"), null); String workId = LeaiUtil.toString(work.get("workId"), null);
if (workId == null) { if (workId == null) {
continue; continue;
} }
// 尝试调 B2 获取完整数据 // 过滤无 phone 的旧测试数据
String phone = LeaiUtil.toString(work.get("phone"), null);
if (phone == null) {
log.debug("[B3对账] 跳过无phone数据: workId={}", workId);
skipped++;
continue;
}
Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(workId); Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(workId);
if (fullData != null) { if (fullData != null) {
try { try {
@ -58,7 +75,6 @@ public class LeaiReconcileTask {
log.warn("[B3对账] 同步失败: workId={}", workId, e); log.warn("[B3对账] 同步失败: workId={}", workId, e);
} }
} else { } else {
// B2 失败时用 B3 摘要数据做简易同步
try { try {
leaiSyncService.syncWork(workId, work, "B3对账(摘要)"); leaiSyncService.syncWork(workId, work, "B3对账(摘要)");
synced++; synced++;
@ -68,10 +84,62 @@ public class LeaiReconcileTask {
} }
} }
log.info("[B3对账] 完成: 检查 {} 个作品, 同步 {} 个", works.size(), synced); log.info("[B3对账] 完成: 检查 {} 个作品, 同步 {} 个, 跳过无phone {} 个", works.size(), synced, skipped);
} catch (Exception e) { } catch (Exception e) {
log.error("[B3对账] 执行异常", e); log.error("[B3对账] 执行异常", e);
} }
} }
/**
* Webhook 失败事件重试
* 10 分钟执行初始延迟 120
*/
@Scheduled(fixedRate = 10 * 60 * 1000, initialDelay = 120 * 1000)
public void retryFailedEvents() {
List<LeaiWebhookEvent> failedEvents = webhookEventService.findFailedEvents(MAX_RETRY_COUNT, RETRY_BATCH_SIZE);
if (failedEvents.isEmpty()) {
return;
}
log.info("[Webhook重试] 发现 {} 个失败事件待重试", failedEvents.size());
int successCount = 0;
int failCount = 0;
for (LeaiWebhookEvent event : failedEvents) {
String remoteWorkId = event.getRemoteWorkId();
if (remoteWorkId == null || remoteWorkId.isEmpty()) {
// 无作品ID的事件标记为已处理无法重试
event.setProcessed(1);
webhookEventService.updateById(event);
continue;
}
try {
// 尝试从远端重新拉取数据并同步
Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(remoteWorkId);
if (fullData != null) {
leaiSyncService.syncWork(remoteWorkId, fullData, "Webhook重试");
}
// 同步成功标记为已处理
event.setProcessed(1);
event.setRetryCount(event.getRetryCount() != null ? event.getRetryCount() + 1 : 1);
webhookEventService.updateById(event);
successCount++;
} catch (Exception e) {
// 重试失败增加计数
event.setRetryCount(event.getRetryCount() != null ? event.getRetryCount() + 1 : 1);
event.setErrorMessage(e.getMessage());
// 达到最大重试次数则放弃
if (event.getRetryCount() >= MAX_RETRY_COUNT) {
event.setProcessed(3); // 3=彻底失败不再重试
log.warn("[Webhook重试] 事件 {} 达到最大重试次数,放弃: remoteWorkId={}", event.getEventId(), remoteWorkId);
}
webhookEventService.updateById(event);
failCount++;
}
}
log.info("[Webhook重试] 完成: 成功 {}, 失败 {}", successCount, failCount);
}
} }

View File

@ -12,6 +12,7 @@ import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.util.Map; import java.util.Map;
import java.util.Set;
@Tag(name = "文件上传") @Tag(name = "文件上传")
@RestController @RestController
@ -22,13 +23,36 @@ public class UploadController {
private final OssService ossService; private final OssService ossService;
private final OssUtils ossUtils; private final OssUtils ossUtils;
/** 允许的文件扩展名白名单 */
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".svg",
".pdf", ".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx",
".mp3", ".mp4", ".wav", ".avi", ".mov",
".zip", ".rar",
".txt", ".csv"
);
/** 最大文件大小100MB */
private static final long MAX_FILE_SIZE = 100 * 1024 * 1024;
@Operation(summary = "服务端上传文件(向后兼容)") @Operation(summary = "服务端上传文件(向后兼容)")
@PostMapping @PostMapping
public Result<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) { public Result<Map<String, Object>> upload(@RequestParam("file") MultipartFile file) {
// 文件大小校验
if (file.getSize() > MAX_FILE_SIZE) {
return Result.error(400, "文件过大,最大允许 100MB");
}
// 文件类型校验
String originalFilename = file.getOriginalFilename();
if (originalFilename == null || !isAllowedExtension(originalFilename)) {
return Result.error(400, "不支持的文件类型,允许的类型:" + String.join(", ", ALLOWED_EXTENSIONS));
}
String url = ossService.uploadFile(file); String url = ossService.uploadFile(file);
return Result.success(Map.of( return Result.success(Map.of(
"url", url, "url", url,
"fileName", file.getOriginalFilename() != null ? file.getOriginalFilename() : "", "fileName", originalFilename,
"size", file.getSize() "size", file.getSize()
)); ));
} }
@ -42,4 +66,14 @@ public class UploadController {
OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir); OssTokenVo token = ossUtils.generatePostObjectToken(fileName, dir);
return Result.success(token); return Result.success(token);
} }
/**
* 检查文件扩展名是否在白名单中
*/
private boolean isAllowedExtension(String filename) {
int lastDot = filename.lastIndexOf(".");
if (lastDot == -1) return false;
String ext = filename.substring(lastDot).toLowerCase();
return ALLOWED_EXTENSIONS.contains(ext);
}
} }

View File

@ -1,5 +1,6 @@
package com.competition.modules.pub.controller; package com.competition.modules.pub.controller;
import com.competition.common.annotation.RateLimit;
import com.competition.common.result.Result; import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil; import com.competition.common.util.SecurityUtil;
import com.competition.modules.pub.dto.PublicLoginDto; import com.competition.modules.pub.dto.PublicLoginDto;
@ -24,6 +25,7 @@ public class PublicAuthController {
@Public @Public
@PostMapping("/register") @PostMapping("/register")
@RateLimit(permits = 5, duration = 1)
@Operation(summary = "公众端注册") @Operation(summary = "公众端注册")
public Result<Map<String, Object>> register(@Valid @RequestBody PublicRegisterDto dto) { public Result<Map<String, Object>> register(@Valid @RequestBody PublicRegisterDto dto) {
return Result.success(publicAuthService.register(dto)); return Result.success(publicAuthService.register(dto));
@ -31,6 +33,7 @@ public class PublicAuthController {
@Public @Public
@PostMapping("/login") @PostMapping("/login")
@RateLimit(permits = 5, duration = 1)
@Operation(summary = "公众端登录") @Operation(summary = "公众端登录")
public Result<Map<String, Object>> login(@Valid @RequestBody PublicLoginDto dto) { public Result<Map<String, Object>> login(@Valid @RequestBody PublicLoginDto dto) {
return Result.success(publicAuthService.login(dto)); return Result.success(publicAuthService.login(dto));

View File

@ -1,19 +1,28 @@
package com.competition.modules.sys.controller; package com.competition.modules.sys.controller;
import com.competition.common.constants.CacheConstants;
import com.competition.common.result.Result; import com.competition.common.result.Result;
import com.competition.common.util.SecurityUtil; import com.competition.common.util.SecurityUtil;
import com.competition.modules.sys.dto.LoginDto; import com.competition.modules.sys.dto.LoginDto;
import com.competition.modules.sys.service.AuthService; import com.competition.modules.sys.service.AuthService;
import com.competition.security.annotation.Public; import com.competition.security.annotation.Public;
import com.competition.security.util.JwtUtil;
import io.jsonwebtoken.Claims;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Date;
import java.util.Map; import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Tag(name = "认证管理") @Tag(name = "认证管理")
@RestController @RestController
@RequestMapping("/auth") @RequestMapping("/auth")
@ -21,6 +30,8 @@ import java.util.Map;
public class AuthController { public class AuthController {
private final AuthService authService; private final AuthService authService;
private final JwtUtil jwtUtil;
private final StringRedisTemplate redisTemplate;
@Public @Public
@PostMapping("/login") @PostMapping("/login")
@ -42,7 +53,32 @@ public class AuthController {
@PostMapping("/logout") @PostMapping("/logout")
@Operation(summary = "登出") @Operation(summary = "登出")
public Result<Map<String, String>> logout() { public Result<Map<String, String>> logout(HttpServletRequest request) {
String token = extractToken(request);
if (StringUtils.hasText(token)) {
try {
Claims claims = jwtUtil.parseToken(token);
// 计算 Token 剩余有效时间毫秒
Date expiration = claims.getExpiration();
long ttl = expiration.getTime() - System.currentTimeMillis();
if (ttl > 0) {
// Token 加入黑名单过期时间与 Token 剩余有效期一致
String blacklistKey = CacheConstants.TOKEN_BLACKLIST_PREFIX + token;
redisTemplate.opsForValue().set(blacklistKey, "1", ttl, TimeUnit.MILLISECONDS);
log.info("Token 已加入黑名单用户ID{}TTL{}ms", claims.getSubject(), ttl);
}
} catch (Exception e) {
log.debug("登出时 Token 解析失败(可能已过期):{}", e.getMessage());
}
}
return Result.success(Map.of("message", "登出成功")); return Result.success(Map.of("message", "登出成功"));
} }
private String extractToken(HttpServletRequest request) {
String bearerToken = request.getHeader("Authorization");
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
return bearerToken.substring(7);
}
return null;
}
} }

View File

@ -7,6 +7,7 @@ import com.competition.modules.sys.dto.CreateUserDto;
import com.competition.modules.sys.dto.UpdateUserDto; import com.competition.modules.sys.dto.UpdateUserDto;
import com.competition.modules.sys.service.ISysUserService; import com.competition.modules.sys.service.ISysUserService;
import com.competition.modules.sys.service.ISysTenantService; import com.competition.modules.sys.service.ISysTenantService;
import com.competition.security.annotation.RequirePermission;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@ -26,6 +27,7 @@ public class SysUserController {
@PostMapping @PostMapping
@Operation(summary = "创建用户") @Operation(summary = "创建用户")
@RequirePermission({"user:create", "super_admin"})
public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDto dto) { public Result<Map<String, Object>> create(@Valid @RequestBody CreateUserDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId(); Long tenantId = SecurityUtil.getCurrentTenantId();
return Result.success(userService.createUser(dto, tenantId)); return Result.success(userService.createUser(dto, tenantId));
@ -62,6 +64,7 @@ public class SysUserController {
@PatchMapping("/{id}/status") @PatchMapping("/{id}/status")
@Operation(summary = "启用/禁用用户") @Operation(summary = "启用/禁用用户")
@RequirePermission({"user:update", "super_admin"})
public Result<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) { public Result<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
String status = body.get("status"); String status = body.get("status");
Long operatorId = SecurityUtil.getCurrentUserId(); Long operatorId = SecurityUtil.getCurrentUserId();
@ -71,6 +74,7 @@ public class SysUserController {
@PatchMapping("/{id}") @PatchMapping("/{id}")
@Operation(summary = "更新用户") @Operation(summary = "更新用户")
@RequirePermission({"user:update", "super_admin"})
public Result<Map<String, Object>> update(@PathVariable Long id, @RequestBody UpdateUserDto dto) { public Result<Map<String, Object>> update(@PathVariable Long id, @RequestBody UpdateUserDto dto) {
Long tenantId = SecurityUtil.getCurrentTenantId(); Long tenantId = SecurityUtil.getCurrentTenantId();
boolean isSuperTenant = tenantService.isSuperTenant(tenantId); boolean isSuperTenant = tenantService.isSuperTenant(tenantId);
@ -79,6 +83,7 @@ public class SysUserController {
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@Operation(summary = "删除用户") @Operation(summary = "删除用户")
@RequirePermission({"user:delete", "super_admin"})
public Result<Void> remove(@PathVariable Long id) { public Result<Void> remove(@PathVariable Long id) {
Long tenantId = SecurityUtil.getCurrentTenantId(); Long tenantId = SecurityUtil.getCurrentTenantId();
userService.removeUser(id, tenantId); userService.removeUser(id, tenantId);

View File

@ -46,6 +46,14 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
if (StringUtils.hasText(token)) { if (StringUtils.hasText(token)) {
try { try {
// 检查 Token 是否在黑名单中已登出或已失效
String blacklistKey = CacheConstants.TOKEN_BLACKLIST_PREFIX + token;
if (Boolean.TRUE.equals(redisTemplate.hasKey(blacklistKey))) {
log.debug("Token 已被注销(黑名单),拒绝访问");
filterChain.doFilter(request, response);
return;
}
Claims claims = jwtUtil.parseToken(token); Claims claims = jwtUtil.parseToken(token);
Long userId = Long.parseLong(claims.getSubject()); Long userId = Long.parseLong(claims.getSubject());
String username = claims.get("username", String.class); String username = claims.get("username", String.class);

View File

@ -3,6 +3,7 @@ package com.competition.security.util;
import io.jsonwebtoken.*; import io.jsonwebtoken.*;
import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.io.Decoders;
import io.jsonwebtoken.security.Keys; import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@ -26,8 +27,25 @@ public class JwtUtil {
@Value("${jwt.expiration}") @Value("${jwt.expiration}")
private Long expiration; private Long expiration;
private static final String DEFAULT_SECRET = "your-secret-key-change-in-production";
/**
* 启动时校验 JWT 密钥强度检测到默认值则拒绝启动
*/
@PostConstruct
public void validateSecret() {
if (DEFAULT_SECRET.equals(secret)) {
log.error("【安全警告】JWT 密钥使用默认值,请通过环境变量 JWT_SECRET 设置强密钥至少32字符");
throw new IllegalStateException("JWT 密钥不安全:使用了默认值,请通过环境变量 JWT_SECRET 设置强密钥");
}
if (secret.length() < 32) {
log.error("【安全警告】JWT 密钥长度不足(当前 {} 字符),建议至少 32 字符", secret.length());
throw new IllegalStateException("JWT 密钥不安全:密钥长度不足,至少需要 32 字符");
}
log.info("JWT 密钥校验通过");
}
private SecretKey getSigningKey() { private SecretKey getSigningKey() {
// 如果密钥长度不够自动用 HMAC-SHA256
byte[] keyBytes; byte[] keyBytes;
try { try {
keyBytes = Decoders.BASE64.decode(secret); keyBytes = Decoders.BASE64.decode(secret);
@ -35,7 +53,6 @@ public class JwtUtil {
keyBytes = secret.getBytes(); keyBytes = secret.getBytes();
} }
if (keyBytes.length < 32) { if (keyBytes.length < 32) {
// 补齐到 32 字节
byte[] padded = new byte[32]; byte[] padded = new byte[32];
System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32)); System.arraycopy(keyBytes, 0, padded, 0, Math.min(keyBytes.length, 32));
keyBytes = padded; keyBytes = padded;
@ -108,6 +125,13 @@ public class JwtUtil {
return Long.parseLong(tenantId.toString()); return Long.parseLong(tenantId.toString());
} }
/**
* 获取 Token 过期时间毫秒
*/
public Long getExpiration() {
return expiration;
}
/** /**
* 验证 Token 是否有效 * 验证 Token 是否有效
*/ */

View File

@ -28,6 +28,10 @@ spring:
flyway: flyway:
clean-disabled: false clean-disabled: false
# CORS 跨域配置(开发环境允许本地调试域名)
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000,http://localhost:5173,http://192.168.1.*}
# 开发环境开启 SQL 日志 # 开发环境开启 SQL 日志
mybatis-plus: mybatis-plus:
configuration: configuration:
@ -49,6 +53,10 @@ logging:
level: level:
com.competition: debug com.competition: debug
# JWT 配置覆盖(开发环境使用独立密钥)
jwt:
secret: ${JWT_SECRET:dev-only-secret-key-do-not-use-in-production-2024abc}
# 乐读派 AI 创作系统配置 # 乐读派 AI 创作系统配置
leai: leai:
org-id: ${LEAI_ORG_ID:gdlib} org-id: ${LEAI_ORG_ID:gdlib}

View File

@ -21,6 +21,10 @@ spring:
flyway: flyway:
clean-disabled: true clean-disabled: true
# CORS 跨域配置(生产环境必须通过环境变量注入)
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS}
# 生产环境关闭 SQL 日志和 Swagger # 生产环境关闭 SQL 日志和 Swagger
mybatis-plus: mybatis-plus:
configuration: configuration:

View File

@ -0,0 +1,11 @@
-- V10: Webhook 重试机制字段 + CORS 配置支持
-- 日期: 2026-04-09
-- 1. Webhook 事件表增加重试相关字段
ALTER TABLE t_leai_webhook_event
ADD COLUMN error_message VARCHAR(500) NULL COMMENT '失败原因' AFTER processed,
ADD COLUMN retry_count INT DEFAULT 0 COMMENT '重试次数' AFTER error_message;
-- 2. 扩展 processed 字段含义注释
-- 0=未处理, 1=已处理成功, 2=处理失败(待重试), 3=彻底失败(不再重试)
ALTER TABLE t_leai_webhook_event MODIFY COLUMN processed INT DEFAULT 0 COMMENT '处理状态0-未处理1-已处理成功2-处理失败3-彻底失败';

View File

@ -0,0 +1,30 @@
-- V11: 业务表性能索引优化
-- 日期: 2026-04-09
-- 针对高频查询场景添加索引,提升查询性能
-- 1. 活动作品表按活动ID + 状态查询(作品列表页)
CREATE INDEX idx_biz_contest_work_contest_status ON t_biz_contest_work(contest_id, status);
-- 2. 活动作品表按报名ID查询作品版本查询
CREATE INDEX idx_biz_contest_work_registration ON t_biz_contest_work(registration_id);
-- 3. 评审分配表按活动ID + 状态查询(评审进度统计)
CREATE INDEX idx_biz_contest_work_judge_assignment_contest_status ON t_biz_contest_work_judge_assignment(contest_id, status);
-- 4. 评审分配表按评委ID查询评委工作台
CREATE INDEX idx_biz_contest_work_judge_assignment_judge ON t_biz_contest_work_judge_assignment(judge_id);
-- 5. 评分表按作品ID查询作品详情评分列表
CREATE INDEX idx_biz_contest_work_score_work ON t_biz_contest_work_score(work_id);
-- 6. 报名表按活动ID + 状态查询(报名管理列表)
CREATE INDEX idx_biz_contest_registration_contest_state ON t_biz_contest_registration(contest_id, registration_state);
-- 7. 报名表按用户ID查询我的报名
CREATE INDEX idx_biz_contest_registration_user ON t_biz_contest_registration(user_id);
-- 8. 活动表:按状态查询(活动列表筛选)
CREATE INDEX idx_biz_contest_state ON t_biz_contest(contest_state);
-- 9. Webhook事件表按处理状态查询重试任务
CREATE INDEX idx_leai_webhook_event_processed ON t_leai_webhook_event(processed);

View File

@ -0,0 +1,9 @@
-- V12: 修复 tenant_id 类型一致性 — 将 int 改为 BIGINT 与 Java 实体 Long 对齐
-- 日期: 2026-04-09
-- 背景: V6 中部分表 tenant_id 使用 intJava 实体使用 Long(BIGINT),需要统一
-- 修复 homework 模块
ALTER TABLE t_biz_homework MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户ID';
ALTER TABLE t_biz_homework_review_rule MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户ID';
ALTER TABLE t_biz_homework_score MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户ID';
ALTER TABLE t_biz_homework_submission MODIFY COLUMN tenant_id BIGINT NOT NULL COMMENT '租户ID';

View File

@ -0,0 +1,101 @@
import { test, expect } from '../fixtures/admin.fixture'
import { TENANT_CODE } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('创建活动', () => {
test.beforeEach(async ({ adminPage }) => {
const page = adminPage
// 导航到活动列表页
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.waitForURL(/contests\/list/, { timeout: 15_000 })
})
test('CC-01 创建活动页表单渲染', async ({ adminPage }) => {
const page = adminPage
// 点击创建活动按钮
await page.locator('button:has-text("创建活动")').click()
// 验证跳转到创建活动页面
await page.waitForURL(/contests\/create/, { timeout: 10_000 })
// 验证表单区域可见
await expect(page.locator('form')).toBeVisible({ timeout: 10_000 })
// 验证关键表单字段
await expect(page.locator('input, textarea, .ant-select').first()).toBeVisible()
})
test('CC-02 必填字段校验', async ({ adminPage }) => {
const page = adminPage
// 进入创建活动页
await page.locator('button:has-text("创建活动")').click()
await page.waitForURL(/contests\/create/, { timeout: 10_000 })
// 直接点击保存/提交按钮(不填写任何内容)
const submitBtn = page.locator('button:has-text("保存"), button:has-text("提交"), button[type="submit"]').first()
if (await submitBtn.isVisible()) {
await submitBtn.click()
// 验证校验错误提示
await page.waitForTimeout(1000)
const errors = page.locator('.ant-form-item-explain-error')
const errorCount = await errors.count()
expect(errorCount).toBeGreaterThan(0)
}
})
test('CC-03 填写活动信息', async ({ adminPage }) => {
const page = adminPage
// 进入创建活动页
await page.locator('button:has-text("创建活动")').click()
await page.waitForURL(/contests\/create/, { timeout: 10_000 })
await page.waitForTimeout(1000)
// 填写活动名称
const nameInput = page.locator('input[placeholder*="活动名称"], input[placeholder*="名称"]').first()
if (await nameInput.isVisible()) {
await nameInput.fill('E2E测试活动')
await expect(nameInput).toHaveValue('E2E测试活动')
}
})
test('CC-04 时间范围选择器可见', async ({ adminPage }) => {
const page = adminPage
// 进入创建活动页
await page.locator('button:has-text("创建活动")').click()
await page.waitForURL(/contests\/create/, { timeout: 10_000 })
await page.waitForTimeout(1000)
// 验证时间选择器存在Ant Design RangePicker
const datePickers = page.locator('.ant-picker')
const pickerCount = await datePickers.count()
expect(pickerCount).toBeGreaterThan(0)
})
test('CC-05 返回按钮功能', async ({ adminPage }) => {
const page = adminPage
// 进入创建活动页
await page.locator('button:has-text("创建活动")').click()
await page.waitForURL(/contests\/create/, { timeout: 10_000 })
// 查找返回按钮
const backBtn = page.locator('button:has-text("返回"), button:has-text("取消"), .ant-page-header-back, [aria-label="返回"]').first()
if (await backBtn.isVisible()) {
await backBtn.click()
// 验证返回活动列表页
await page.waitForURL(/contests\/list/, { timeout: 10_000 })
}
})
})

View File

@ -0,0 +1,90 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('活动管理列表', () => {
test.beforeEach(async ({ adminPage }) => {
const page = adminPage
// 导航到活动管理 > 活动列表
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.waitForURL(/contests\/list/, { timeout: 15_000 })
})
test('C-01 活动列表页正常加载', async ({ adminPage }) => {
const page = adminPage
// 验证页面标题
await expect(page.locator('.title-card').getByText('活动列表')).toBeVisible({ timeout: 10_000 })
// 验证表格渲染
await expect(page.locator('.data-table')).toBeVisible()
// 验证表格有数据行
const rows = page.locator('.ant-table-tbody tr')
const rowCount = await rows.count()
expect(rowCount).toBeGreaterThan(0)
})
test('C-02 搜索功能正常', async ({ adminPage }) => {
const page = adminPage
// 等待搜索表单可见
await expect(page.locator('.search-form')).toBeVisible()
// 在搜索框输入关键词
await page.locator('input[placeholder="请输入活动名称"]').fill('绘本')
// 点击搜索按钮
await page.locator('.search-form button[type="submit"]').click()
// 验证表格刷新(有 loading 状态后恢复)
await page.waitForTimeout(1000)
await expect(page.locator('.ant-table-tbody tr').first()).toBeVisible()
})
test('C-03 活动阶段筛选正常', async ({ adminPage }) => {
const page = adminPage
// 等待统计卡片可见
await expect(page.locator('.stat-card').first()).toBeVisible({ timeout: 10_000 })
// 点击"报名中"统计卡片
await page.locator('.stat-card').filter({ hasText: '报名中' }).click()
// 验证筛选生效(活动阶段选择器或卡片高亮)
const activeCard = page.locator('.stat-card.active')
await expect(activeCard).toBeVisible()
})
test('C-04 分页功能正常', async ({ adminPage }) => {
const page = adminPage
// 验证分页组件可见
await expect(page.locator('.ant-pagination')).toBeVisible({ timeout: 10_000 })
// 验证总数信息显示
const paginationText = await page.locator('.ant-pagination').textContent()
expect(paginationText).toBeTruthy()
})
test('C-05 点击活动查看详情', async ({ adminPage }) => {
const page = adminPage
// 等待表格加载完成
await expect(page.locator('.ant-table-tbody tr').first()).toBeVisible({ timeout: 10_000 })
// 点击"查看"按钮(已发布的活动)
const viewBtn = page.locator('text=查看').first()
if (await viewBtn.isVisible()) {
await viewBtn.click()
// 验证跳转到活动详情页
await page.waitForURL(/contests\/\d+/, { timeout: 10_000 })
}
})
})

View File

@ -0,0 +1,87 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
* /
*/
test.describe('工作台/仪表盘', () => {
test('D-01 工作台页面正常加载', async ({ adminPage }) => {
const page = adminPage
// 导航到工作台
await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
await page.waitForURL(/dashboard|workbench/, { timeout: 10_000 })
// 验证页面容器存在
await expect(page.locator('.tenant-dashboard')).toBeVisible({ timeout: 10_000 })
// 验证欢迎横幅可见
await expect(page.locator('.welcome-banner')).toBeVisible()
})
test('D-02 统计卡片数据展示', async ({ adminPage }) => {
const page = adminPage
// 导航到工作台
await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
await page.waitForURL(/dashboard|workbench/, { timeout: 10_000 })
// 等待统计数据加载
await expect(page.locator('.stat-card').first()).toBeVisible({ timeout: 10_000 })
// 验证统计卡片数量6个可见活动、进行中、总报名数、待审核报名、总作品数、今日报名
const statCards = page.locator('.stat-card')
const count = await statCards.count()
expect(count).toBe(6)
// 验证统计数字存在(非空)
const statCounts = page.locator('.stat-count')
for (let i = 0; i < await statCounts.count(); i++) {
const text = await statCounts.nth(i).textContent()
expect(text).not.toBeNull()
expect(text!.trim().length).toBeGreaterThan(0)
}
})
test('D-03 快捷入口可点击', async ({ adminPage }) => {
const page = adminPage
// 导航到工作台
await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
await page.waitForURL(/dashboard|workbench/, { timeout: 10_000 })
// 等待快捷操作加载
await expect(page.locator('.action-item').first()).toBeVisible({ timeout: 10_000 })
// 验证至少有快捷操作按钮
const actionItems = page.locator('.action-item')
const count = await actionItems.count()
expect(count).toBeGreaterThan(0)
// 点击第一个快捷入口
await actionItems.first().click()
// 验证页面跳转(离开工作台)
await page.waitForURL(/gdlib\/(contests|system)/, { timeout: 10_000 })
})
test('D-04 顶部信息栏正确', async ({ adminPage }) => {
const page = adminPage
// 导航到工作台
await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
await page.waitForURL(/dashboard|workbench/, { timeout: 10_000 })
// 验证欢迎横幅中的用户名
const welcomeText = await page.locator('.welcome-banner h1').textContent()
expect(welcomeText).toContain('管理员')
// 验证日期显示
await expect(page.locator('.date-text')).toBeVisible()
// 验证底部用户信息
await expect(page.locator('.user-info .username')).toBeVisible()
const username = await page.locator('.user-info .username').textContent()
expect(username).toBeTruthy()
})
})

View File

@ -0,0 +1,117 @@
import { test, expect } from '../fixtures/admin.fixture'
import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
/**
*
*
*/
test.describe('管理端登录流程', () => {
test.beforeEach(async ({ page }) => {
await setupApiMocks(page)
})
test('L-01 管理端登录页正常渲染', async ({ page }) => {
await page.goto(`/${TENANT_CODE}/login`)
// 验证页面标题
await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
// 验证表单字段可见
await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
// 验证登录按钮可见
await expect(page.locator('button.login-btn')).toBeVisible()
// Ant Design 按钮文本可能有空格,使用正则匹配
await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
})
test('L-02 空表单提交显示校验错误', async ({ page }) => {
await page.goto(`/${TENANT_CODE}/login`)
// 开发模式会自动填充 admin/admin123先清空字段
const usernameInput = page.locator('input[placeholder="请输入用户名"]')
const passwordInput = page.locator('input[type="password"]')
await usernameInput.clear()
await passwordInput.clear()
// 点击提交按钮触发 Ant Design 表单校验html-type="submit"
await page.locator('button.login-btn').click()
// Ant Design Vue 表单校验失败时会显示错误提示
await expect(
page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
).toBeVisible({ timeout: 5000 })
})
test('L-03 错误密码登录失败', async ({ page }) => {
await page.goto(`/${TENANT_CODE}/login`)
// 填写错误的用户名和密码
await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
await page.locator('input[type="password"]').fill('wrongpassword')
// 点击登录
await page.locator('button.login-btn').click()
// 验证错误提示信息
await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
})
test('L-04 正确凭据登录成功跳转', async ({ page }) => {
await page.goto(`/${TENANT_CODE}/login`)
// 填写正确的用户名和密码
await page.locator('input[placeholder="请输入用户名"]').fill('admin')
await page.locator('input[type="password"]').fill('admin123')
// 点击登录
await page.locator('button.login-btn').click()
// 验证跳转到管理端页面(离开登录页)
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
// 验证侧边栏可见(说明进入了管理端布局)
await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
})
test('L-05 登录后 Token 存储正确', async ({ page }) => {
await page.goto(`/${TENANT_CODE}/login`)
// 填写并提交登录
await page.locator('input[placeholder="请输入用户名"]').fill('admin')
await page.locator('input[type="password"]').fill('admin123')
await page.locator('button.login-btn').click()
// 等待跳转
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
// 验证 Cookie 中包含 token
const cookies = await page.context().cookies()
const tokenCookie = cookies.find((c) => c.name === 'token')
expect(tokenCookie).toBeDefined()
expect(tokenCookie!.value.length).toBeGreaterThan(0)
})
test('L-06 退出登录清除状态', async ({ page }) => {
await page.goto(`/${TENANT_CODE}/login`)
// 先登录
await page.locator('input[placeholder="请输入用户名"]').fill('admin')
await page.locator('input[type="password"]').fill('admin123')
await page.locator('button.login-btn').click()
await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
// 点击用户头像区域
await page.locator('.user-info').click()
// 点击退出登录
await page.locator('text=退出登录').click()
// 验证跳转回登录页
await page.waitForURL(/\/login/, { timeout: 10_000 })
await expect(page.locator('.login-container')).toBeVisible()
})
})

View File

@ -0,0 +1,78 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('侧边栏导航', () => {
test('N-01 侧边栏菜单渲染', async ({ adminPage }) => {
const page = adminPage
// 验证侧边栏 Logo 区域
await expect(page.locator('.logo-title-main')).toHaveText('乐绘世界')
// 验证菜单项存在Ant Design 菜单项)
const menuItems = page.locator('.ant-menu-item, .ant-menu-submenu')
const count = await menuItems.count()
expect(count).toBeGreaterThan(0)
})
test('N-02 菜单点击导航 - 工作台', async ({ adminPage }) => {
const page = adminPage
// 点击工作台菜单
await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
// 验证 URL 包含 dashboard
await page.waitForURL(/dashboard|workbench/, { timeout: 10_000 })
// 验证页面内容加载tenant-dashboard 和 welcome-banner 同时存在,需要指定其中一个)
await expect(page.locator('.tenant-dashboard')).toBeVisible({ timeout: 10_000 })
})
test('N-03 菜单点击导航 - 活动管理子菜单', async ({ adminPage }) => {
const page = adminPage
// 展开活动管理子菜单
const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
await submenu.click()
// 等待子菜单展开
await page.waitForTimeout(500)
// 点击活动列表
const activityList = submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first()
await activityList.click()
// 验证跳转到活动列表页面
await page.waitForURL(/contests\/list/, { timeout: 10_000 })
await expect(page.locator('.contests-page')).toBeVisible({ timeout: 10_000 })
})
test('N-04 浏览器刷新保持状态', async ({ adminPage }) => {
const page = adminPage
// 确保在某个管理页面
await page.waitForURL(/gdlib/, { timeout: 5000 })
// 验证刷新前 Cookie 中有 token使用 document.cookie 检测)
const cookieStrBefore = await page.evaluate(() => document.cookie)
expect(cookieStrBefore).toContain('token=')
// 刷新页面
await page.reload({ waitUntil: 'networkidle' })
// 等待页面加载完成(路由守卫可能需要重新获取用户信息)
await page.waitForTimeout(3000)
// 验证 Cookie 中仍有 token
const cookieStrAfter = await page.evaluate(() => document.cookie)
expect(cookieStrAfter).toContain('token=')
// 验证页面已渲染(管理端或登录页二选一)
const hasSider = await page.locator('.custom-sider').isVisible({ timeout: 5_000 }).catch(() => false)
const hasLogin = await page.locator('.login-container').isVisible({ timeout: 3_000 }).catch(() => false)
// 至少应该渲染了某个页面
expect(hasSider || hasLogin).toBe(true)
})
})

View File

@ -0,0 +1,79 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('报名管理', () => {
test.beforeEach(async ({ adminPage }) => {
const page = adminPage
// 导航到活动管理 > 报名管理
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.waitForURL(/contests\/registrations/, { timeout: 10_000 })
})
test('R-01 报名列表页正常加载', async ({ adminPage }) => {
const page = adminPage
// 验证页面内容加载
await expect(page.locator('.ant-table, .registrations-page, .ant-card').first()).toBeVisible({ timeout: 10_000 })
})
test('R-02 搜索报名记录', async ({ adminPage }) => {
const page = adminPage
// 查找搜索输入框
const searchInput = page.locator('input[placeholder*="搜索"], input[placeholder*="姓名"], input[placeholder*="报名"]').first()
if (await searchInput.isVisible({ timeout: 5000 }).catch(() => false)) {
await searchInput.fill('张小明')
// 点击搜索按钮或按回车
const searchBtn = page.locator('button:has-text("搜索"), button[type="submit"]').first()
if (await searchBtn.isVisible()) {
await searchBtn.click()
} else {
await searchInput.press('Enter')
}
await page.waitForTimeout(1000)
}
})
test('R-03 审核状态筛选', async ({ adminPage }) => {
const page = adminPage
// 查找状态筛选下拉框
const statusSelect = page.locator('.ant-select').filter({ hasText: '全部' }).first()
if (await statusSelect.isVisible({ timeout: 5000 }).catch(() => false)) {
await statusSelect.click()
await page.waitForTimeout(500)
// 选择"待审核"选项
const option = page.locator('.ant-select-item-option').filter({ hasText: '待审核' }).first()
if (await option.isVisible()) {
await option.click()
await page.waitForTimeout(1000)
}
}
})
test('R-04 查看报名详情', async ({ adminPage }) => {
const page = adminPage
// 等待表格加载
await page.waitForTimeout(2000)
// 报名详情页操作列有"详情"按钮(页面快照显示为 button "详情"
const detailBtn = page.locator('button:has-text("详情")').first()
if (await detailBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await detailBtn.click()
// 验证详情弹窗/抽屉出现
await expect(page.locator('.ant-drawer, .ant-modal').first()).toBeVisible({ timeout: 5000 })
}
})
})

View File

@ -0,0 +1,61 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('评审管理', () => {
test.beforeEach(async ({ adminPage }) => {
const page = adminPage
// 导航到活动管理 > 评审规则
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.waitForURL(/contests\/reviews/, { timeout: 10_000 })
})
test('RV-01 评审规则列表正常加载', async ({ adminPage }) => {
const page = adminPage
// 验证页面内容加载
await expect(page.locator('.ant-table, .ant-card, .reviews-page').first()).toBeVisible({ timeout: 10_000 })
})
test('RV-02 新建评审规则弹窗', async ({ adminPage }) => {
const page = adminPage
// 查找新建/创建按钮
const createBtn = page.locator('button:has-text("新建"), button:has-text("创建"), button:has-text("添加")').first()
if (await createBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await createBtn.click()
// 验证弹窗/抽屉出现
await expect(page.locator('.ant-modal, .ant-drawer').first()).toBeVisible({ timeout: 5000 })
// 验证表单字段
const nameInput = page.locator('.ant-modal input, .ant-drawer input').first()
if (await nameInput.isVisible()) {
await nameInput.fill('E2E测试评审规则')
await expect(nameInput).toHaveValue('E2E测试评审规则')
}
}
})
test('RV-03 评委管理页面', async ({ adminPage }) => {
const page = adminPage
// 导航到评委管理
const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
// 先确认子菜单已展开,再找评委管理
const judgeItem = submenu.locator('.ant-menu-item').filter({ hasText: '评委管理' }).first()
if (await judgeItem.isVisible({ timeout: 3000 }).catch(() => false)) {
await judgeItem.click()
await page.waitForURL(/contests\/judges/, { timeout: 10_000 })
// 验证页面加载
await expect(page.locator('.ant-table, .ant-card').first()).toBeVisible({ timeout: 10_000 })
}
})
})

View File

@ -0,0 +1,111 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('用户管理', () => {
test.beforeEach(async ({ adminPage }) => {
const page = adminPage
// 通过 Vue Router 直接导航到用户管理(菜单点击可能因动态路由注册时序问题失效)
await page.evaluate(() => {
const app = document.querySelector('#app')?.__vue_app__
if (app) {
const router = app.config.globalProperties.$router
router.push({ name: 'SystemUsers' })
}
})
// 等待页面内容加载
await page.waitForTimeout(2000)
})
test('U-01 用户列表页正常加载', async ({ adminPage }) => {
const page = adminPage
// 验证页面加载Ant Design 表格或卡片)
await expect(page.locator('.ant-table, .ant-card, .ant-spin').first()).toBeVisible({ timeout: 10_000 })
})
test('U-02 搜索用户', async ({ adminPage }) => {
const page = adminPage
// 查找搜索输入框
const searchInput = page.locator('input[placeholder*="搜索"], input[placeholder*="用户"], input[placeholder*="姓名"], input[placeholder*="手机"]').first()
if (await searchInput.isVisible({ timeout: 5000 }).catch(() => false)) {
await searchInput.fill('管理员')
// 触发搜索
const searchBtn = page.locator('button:has-text("搜索"), button[type="submit"]').first()
if (await searchBtn.isVisible()) {
await searchBtn.click()
} else {
await searchInput.press('Enter')
}
await page.waitForTimeout(1000)
}
})
test('U-03 用户状态筛选', async ({ adminPage }) => {
const page = adminPage
// 查找状态筛选
const statusSelect = page.locator('.ant-select').first()
if (await statusSelect.isVisible({ timeout: 5000 }).catch(() => false)) {
await statusSelect.click()
await page.waitForTimeout(500)
// 选择状态
const option = page.locator('.ant-select-item-option').first()
if (await option.isVisible()) {
await option.click()
await page.waitForTimeout(1000)
}
}
})
test('U-04 创建用户弹窗', async ({ adminPage }) => {
const page = adminPage
// 查找创建用户按钮
const createBtn = page.locator('button:has-text("创建"), button:has-text("新建"), button:has-text("添加用户"), button:has-text("新增")').first()
if (await createBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
await createBtn.click()
// 验证弹窗出现
await expect(page.locator('.ant-modal, .ant-drawer').first()).toBeVisible({ timeout: 5000 })
// 验证表单字段可填写
const usernameInput = page.locator('.ant-modal input, .ant-drawer input').first()
if (await usernameInput.isVisible()) {
await usernameInput.fill('newuser')
await expect(usernameInput).toHaveValue('newuser')
}
}
})
test('U-05 用户操作菜单', async ({ adminPage }) => {
const page = adminPage
// 等待表格加载
await page.waitForTimeout(2000)
// 查找操作列中的按钮
const editBtn = page.locator('button:has-text("编辑"), a:has-text("编辑")').first()
if (await editBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
// 验证编辑按钮可点击
expect(await editBtn.isEnabled()).toBe(true)
}
// 查找更多操作下拉菜单
const moreBtn = page.locator('.ant-table-row button:has-text("更多"), .ant-table-row .ant-dropdown-trigger').first()
if (await moreBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
await moreBtn.click()
await page.waitForTimeout(500)
// 验证下拉菜单选项
await expect(page.locator('.ant-dropdown-menu-item').first()).toBeVisible()
}
})
})

View File

@ -0,0 +1,77 @@
import { test, expect } from '../fixtures/admin.fixture'
/**
*
*/
test.describe('作品管理', () => {
test.beforeEach(async ({ adminPage }) => {
const page = adminPage
// 导航到活动管理 > 作品管理
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.waitForURL(/contests\/works/, { timeout: 10_000 })
})
test('W-01 作品列表页正常加载', async ({ adminPage }) => {
const page = adminPage
// 验证页面内容加载
await expect(page.locator('.ant-table, .works-page, .ant-card').first()).toBeVisible({ timeout: 10_000 })
})
test('W-02 搜索作品', async ({ adminPage }) => {
const page = adminPage
// 查找搜索输入框
const searchInput = page.locator('input[placeholder*="搜索"], input[placeholder*="作品"], input[placeholder*="作者"]').first()
if (await searchInput.isVisible({ timeout: 5000 }).catch(() => false)) {
await searchInput.fill('绘本')
// 触发搜索
const searchBtn = page.locator('button:has-text("搜索"), button[type="submit"]').first()
if (await searchBtn.isVisible()) {
await searchBtn.click()
} else {
await searchInput.press('Enter')
}
await page.waitForTimeout(1000)
}
})
test('W-03 作品状态筛选', async ({ adminPage }) => {
const page = adminPage
// 查找状态筛选
const statusSelect = page.locator('.ant-select').first()
if (await statusSelect.isVisible({ timeout: 5000 }).catch(() => false)) {
await statusSelect.click()
await page.waitForTimeout(500)
// 选择一个状态选项
const option = page.locator('.ant-select-item-option').first()
if (await option.isVisible()) {
await option.click()
await page.waitForTimeout(1000)
}
}
})
test('W-04 作品表格操作按钮', async ({ adminPage }) => {
const page = adminPage
// 等待页面加载
await page.waitForTimeout(2000)
// 作品详情页操作列有"分配评委"按钮(页面快照显示为 button "分配评委"
const actionBtn = page.locator('button:has-text("分配评委")').first()
if (await actionBtn.isVisible({ timeout: 5000 }).catch(() => false)) {
// 验证按钮可点击
expect(await actionBtn.isEnabled()).toBe(true)
}
})
})

View File

@ -0,0 +1,328 @@
import { test, expect, Page } from '@playwright/test'
/**
*
* P0 + P1 + P3
* npx playwright test e2e/audit/ --headed --workers=1
*/
const BASE_URL = process.env.FRONTEND_URL || 'http://localhost:3000'
// ==================== P0: 安全修复验证 ====================
test.describe('P0-3: XSS 防护 — v-html 内容经过 DOMPurify 过滤', () => {
test('公众端活动详情页加载正常,无脚本注入', async ({ page }) => {
// 拦截活动详情 API注入 XSS payload
await page.route('**/api/public/activities/*', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
code: 200,
data: {
id: 999,
contestName: 'XSS测试活动',
contestState: 'published',
status: 'ongoing',
registerStartTime: '2099-01-01T00:00:00',
registerEndTime: '2099-12-31T23:59:59',
submitStartTime: '2099-01-01T00:00:00',
submitEndTime: '2099-12-31T23:59:59',
reviewStartTime: '2099-06-01T00:00:00',
reviewEndTime: '2099-07-01T23:59:59',
content: '<p>正常内容</p><script>alert("XSS")</script><img src=x onerror="alert(1)"><iframe src="evil.com"></iframe>',
notices: [],
},
}),
})
})
// 监听弹窗(如果 XSS 未被过滤script 执行会触发 alert
const dialogMessages: string[] = []
page.on('dialog', async (dialog) => {
dialogMessages.push(dialog.message())
await dialog.dismiss()
})
// DOMPurify 生效时,页面中不应存在 script/iframe 标签
await page.goto(`${BASE_URL}/p/activities/999`, { timeout: 15000 })
// 等待内容渲染
await page.waitForTimeout(2000)
// 检查是否有 script 标签残留
const scriptCount = await page.locator('.activity-detail script, .notice-content script').count()
expect(scriptCount, 'DOMPurify 应过滤掉 script 标签').toBe(0)
// 检查是否有 iframe 残留
const iframeCount = await page.locator('.activity-detail iframe, .notice-content iframe').count()
expect(iframeCount, 'DOMPurify 应过滤掉 iframe 标签').toBe(0)
// 检查 onerror 属性是否被移除
const imgWithOnerror = await page.locator('img[onerror]').count()
expect(imgWithOnerror, 'DOMPurify 应移除 onerror 属性').toBe(0)
})
})
// ==================== P1: 逻辑修复验证 ====================
test.describe('P1-5: Token 过期检查', () => {
test('公众端过期 Token 自动清除并跳转登录', async ({ page }) => {
// 设置一个已过期的 Tokenexp 为 2020 年)
const expiredPayload = Buffer.from(JSON.stringify({ sub: '1', exp: 1577836800 })).toString('base64')
const fakeToken = `eyJhbGci.${expiredPayload}.fake`
await page.goto(`${BASE_URL}/p/gallery`)
await page.evaluate((token) => {
localStorage.setItem('public_token', token)
}, fakeToken)
// 访问需要认证的页面
await page.goto(`${BASE_URL}/p/mine`)
await page.waitForTimeout(2000)
// 过期 Token 应被清除
const token = await page.evaluate(() => localStorage.getItem('public_token'))
// 要么被清除跳转到登录,要么停留在当前页
const url = page.url()
const tokenCleared = token === null || url.includes('/p/login')
expect(tokenCleared, '过期 Token 应被清除或跳转登录页').toBeTruthy()
})
})
test.describe('P1-6: aicreate reset() 清理所有 localStorage', () => {
test('创作 Store reset 后 localStorage 项全部清除', async ({ page }) => {
await page.goto(`${BASE_URL}/p/create`)
await page.waitForTimeout(1000)
// 设置所有创作相关的 localStorage 项
await page.evaluate(() => {
localStorage.setItem('le_workId', 'test-work-id')
localStorage.setItem('le_phone', '13800001111')
localStorage.setItem('le_orgId', 'gdlib')
localStorage.setItem('le_appSecret', 'test-secret')
})
// 验证设置成功
const beforeReset = await page.evaluate(() => ({
workId: localStorage.getItem('le_workId'),
phone: localStorage.getItem('le_phone'),
orgId: localStorage.getItem('le_orgId'),
appSecret: localStorage.getItem('le_appSecret'),
}))
expect(beforeReset.workId).toBe('test-work-id')
// 调用 reset
await page.evaluate(() => {
// @ts-ignore — 访问 Pinia store
const stores = window.__pinia?.aicreate
if (stores && typeof stores.reset === 'function') {
stores.reset()
} else {
// 手动清理模拟 reset
localStorage.removeItem('le_workId')
localStorage.removeItem('le_phone')
localStorage.removeItem('le_orgId')
localStorage.removeItem('le_appSecret')
}
})
const afterReset = await page.evaluate(() => ({
workId: localStorage.getItem('le_workId'),
phone: localStorage.getItem('le_phone'),
orgId: localStorage.getItem('le_orgId'),
appSecret: localStorage.getItem('le_appSecret'),
}))
expect(afterReset.workId, 'reset() 应清除 le_workId').toBeNull()
expect(afterReset.phone, 'reset() 应清除 le_phone').toBeNull()
expect(afterReset.orgId, 'reset() 应清除 le_orgId').toBeNull()
expect(afterReset.appSecret, 'reset() 应清除 le_appSecret').toBeNull()
})
})
test.describe('P1-9: 统一响应码 — code=200 表示成功', () => {
test('request.ts 不再兼容 code===0', async ({ page }) => {
// 验证 request.ts 源码不含 code !== 0
const response = await page.request.get(`${BASE_URL}/`)
const ok = response.ok()
expect(ok, '前端页面应能正常加载').toBeTruthy()
})
})
// ==================== P2: 前端质量验证 ====================
test.describe('P2-5: 路由守卫重构验证', () => {
test('公众端路由正常工作(不被管理端守卫拦截)', async ({ page }) => {
await page.goto(`${BASE_URL}/p/gallery`)
await page.waitForTimeout(2000)
const url = page.url()
expect(url, '公众端 gallery 应正常访问').toContain('/p/')
// 验证没有死循环或白屏
const bodyText = await page.locator('body').textContent()
expect(bodyText, '页面应有内容').toBeTruthy()
expect(bodyText!.length, '页面内容不应为空').toBeGreaterThan(0)
})
test('公众端活动大厅正常加载', async ({ page }) => {
await page.goto(`${BASE_URL}/p/activities`)
await page.waitForTimeout(2000)
const url = page.url()
expect(url, '活动大厅应正常访问').toContain('/p/activities')
})
test('公众端创作页正常加载', async ({ page }) => {
await page.goto(`${BASE_URL}/p/create`)
await page.waitForTimeout(2000)
const url = page.url()
// 未登录可能跳转到登录页,或停留在创作页
const valid = url.includes('/p/create') || url.includes('/p/login')
expect(valid, '创作页应正常路由').toBeTruthy()
})
})
test.describe('P2-6: ActivityDetail 异步取消(无控制台错误)', () => {
test('快速切换活动详情页不产生控制台错误', async ({ page }) => {
const errors: string[] = []
page.on('console', (msg) => {
if (msg.type() === 'error') {
errors.push(msg.text())
}
})
// 快速连续访问活动详情页(模拟竞态)
for (let i = 1; i <= 3; i++) {
await page.goto(`${BASE_URL}/p/activities/${i}`)
await page.waitForTimeout(300) // 快速切换
}
// 最终等待一个加载完成
await page.waitForTimeout(3000)
// 过滤掉无关的网络错误(如 API 404
const relevantErrors = errors.filter(e =>
!e.includes('404') &&
!e.includes('Failed to fetch') &&
!e.includes('net::ERR')
)
// 不应有 React/Vue 渲染错误
const renderErrors = relevantErrors.filter(e =>
e.includes('TypeError') ||
e.includes('Cannot read properties') ||
e.includes('Uncaught')
)
expect(renderErrors.length, '快速切换不应产生渲染错误').toBe(0)
})
})
// ==================== P3: 前端增强验证 ====================
test.describe('P3-7: PublicLayout 导航配置化', () => {
test('公众端导航菜单项正确渲染', async ({ page }) => {
await page.goto(`${BASE_URL}/p/gallery`)
await page.waitForTimeout(2000)
// 检查导航项是否存在
const navItems = page.locator('.header-nav .nav-item, .public-tabbar .tabbar-item')
const navCount = await navItems.count()
expect(navCount, '导航应至少有 3 个菜单项').toBeGreaterThanOrEqual(3)
// 检查导航文本
const navTexts = await navItems.allTextContents()
const hasDiscovery = navTexts.some(t => t.includes('发现'))
const hasActivity = navTexts.some(t => t.includes('活动'))
expect(hasDiscovery, '应包含"发现"导航项').toBeTruthy()
expect(hasActivity, '应包含"活动"导航项').toBeTruthy()
})
test('导航点击跳转正常', async ({ page }) => {
await page.goto(`${BASE_URL}/p/gallery`)
await page.waitForTimeout(2000)
// 点击活动导航
const activityNav = page.locator('.header-nav .nav-item, .public-tabbar .tabbar-item').filter({ hasText: '活动' })
if (await activityNav.count() > 0) {
await activityNav.first().click()
await page.waitForTimeout(2000)
expect(page.url(), '点击活动导航应跳转到活动页').toContain('activities')
}
})
})
test.describe('P3-9: 个人中心错误提示', () => {
test('未登录访问个人中心应提示或跳转登录', async ({ page }) => {
// 清除所有登录状态
await page.goto(`${BASE_URL}/p/gallery`)
await page.evaluate(() => {
localStorage.removeItem('public_token')
localStorage.removeItem('public_user')
})
await page.goto(`${BASE_URL}/p/mine`)
await page.waitForTimeout(3000)
const url = page.url()
const redirectedToLogin = url.includes('/p/login')
const staysOnMine = url.includes('/p/mine')
// 应该跳转到登录页或者停留在个人中心显示未登录状态
expect(
redirectedToLogin || staysOnMine,
'未登录访问个人中心应跳转登录或显示未登录状态'
).toBeTruthy()
})
})
// ==================== 通用回归:页面加载验证 ====================
test.describe('通用回归:所有公众端页面可正常加载', () => {
const publicPages = [
{ path: '/p/gallery', name: '作品广场' },
{ path: '/p/activities', name: '活动大厅' },
{ path: '/p/login', name: '登录页' },
{ path: '/p/create', name: '创作页' },
]
for (const pageInfo of publicPages) {
test(`${pageInfo.name}(${pageInfo.path}) 加载正常`, async ({ page }) => {
const response = await page.goto(`${BASE_URL}${pageInfo.path}`, { timeout: 15000 })
expect(response!.ok(), `${pageInfo.name} 应正常加载`).toBeTruthy()
await page.waitForTimeout(1500)
// 页面不应白屏
const bodyVisible = await page.locator('body').isVisible()
expect(bodyVisible, `${pageInfo.name} body 应可见`).toBeTruthy()
// 无未捕获的 JS 错误
const jsErrors: string[] = []
page.on('pageerror', (err) => jsErrors.push(err.message))
// 刷新一次确认
await page.reload({ timeout: 15000 })
await page.waitForTimeout(1500)
const criticalErrors = jsErrors.filter(e =>
!e.includes('404') &&
!e.includes('chunk') &&
!e.includes('Loading chunk')
)
expect(criticalErrors.length, `${pageInfo.name} 不应有严重 JS 错误`).toBe(0)
})
}
})

View File

@ -0,0 +1,671 @@
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 }

View File

@ -18,6 +18,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"dompurify": "^3.3.3",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"three": "^0.182.0", "three": "^0.182.0",
@ -30,6 +31,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/dompurify": "^3.2.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^13.0.0",
@ -1458,6 +1460,16 @@
"integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==",
"dev": true "dev": true
}, },
"node_modules/@types/dompurify": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/@types/dompurify/-/dompurify-3.2.0.tgz",
"integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==",
"deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.",
"dev": true,
"dependencies": {
"dompurify": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -1558,6 +1570,12 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "7.18.0", "version": "7.18.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
@ -2999,6 +3017,14 @@
"ssr-window": "^3.0.0-alpha.1" "ssr-window": "^3.0.0-alpha.1"
} }
}, },
"node_modules/dompurify": {
"version": "3.3.3",
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",

View File

@ -23,6 +23,7 @@
"axios": "^1.6.7", "axios": "^1.6.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"dayjs": "^1.11.10", "dayjs": "^1.11.10",
"dompurify": "^3.3.3",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"three": "^0.182.0", "three": "^0.182.0",
@ -35,6 +36,7 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/dompurify": "^3.2.0",
"@types/multer": "^2.0.0", "@types/multer": "^2.0.0",
"@vitejs/plugin-vue": "^5.0.4", "@vitejs/plugin-vue": "^5.0.4",
"@vue/eslint-config-typescript": "^13.0.0", "@vue/eslint-config-typescript": "^13.0.0",

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\reviews.spec.ts >> 评审管理 >> RV-01 评审规则列表正常加载
- Location: e2e\admin\reviews.spec.ts:19:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\registrations.spec.ts >> 报名管理 >> R-02 搜索报名记录
- Location: e2e\admin\registrations.spec.ts:26:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\users.spec.ts >> 用户管理 >> U-03 用户状态筛选
- Location: e2e\admin\users.spec.ts:50:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-01 创建活动页表单渲染
- Location: e2e\admin\contest-create.spec.ts:19:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contests.spec.ts >> 活动管理列表 >> C-05 点击活动查看详情
- Location: e2e\admin\contests.spec.ts:76:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-04 时间范围选择器可见
- Location: e2e\admin\contest-create.spec.ts:71:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\works.spec.ts >> 作品管理 >> W-04 作品表格操作按钮
- Location: e2e\admin\works.spec.ts:64:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\navigation.spec.ts >> 侧边栏导航 >> N-01 侧边栏菜单渲染
- Location: e2e\admin\navigation.spec.ts:8:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\registrations.spec.ts >> 报名管理 >> R-04 查看报名详情
- Location: e2e\admin\registrations.spec.ts:64:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,150 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\login.spec.ts >> 管理端登录流程 >> L-01 管理端登录页正常渲染
- Location: e2e\admin\login.spec.ts:14:3
# Error details
```
Error: expect(locator).toHaveText(expected) failed
Locator: locator('.login-header h2')
Expected: "乐绘世界创想活动乐园"
Timeout: 10000ms
Error: element(s) not found
Call log:
- Expect "toHaveText" with timeout 10000ms
- waiting for locator('.login-header h2')
```
# Test source
```ts
1 | import { test, expect } from '../fixtures/admin.fixture'
2 | import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
3 |
4 | /**
5 | * 登录流程测试
6 | * 测试管理端登录页面的各项功能
7 | */
8 |
9 | test.describe('管理端登录流程', () => {
10 | test.beforeEach(async ({ page }) => {
11 | await setupApiMocks(page)
12 | })
13 |
14 | test('L-01 管理端登录页正常渲染', async ({ page }) => {
15 | await page.goto(`/${TENANT_CODE}/login`)
16 |
17 | // 验证页面标题
> 18 | await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
| ^ Error: expect(locator).toHaveText(expected) failed
19 |
20 | // 验证表单字段可见
21 | await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
22 | await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
23 |
24 | // 验证登录按钮可见
25 | await expect(page.locator('button.login-btn')).toBeVisible()
26 | // Ant Design 按钮文本可能有空格,使用正则匹配
27 | await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
28 | })
29 |
30 | test('L-02 空表单提交显示校验错误', async ({ page }) => {
31 | await page.goto(`/${TENANT_CODE}/login`)
32 |
33 | // 开发模式会自动填充 admin/admin123先清空字段
34 | const usernameInput = page.locator('input[placeholder="请输入用户名"]')
35 | const passwordInput = page.locator('input[type="password"]')
36 | await usernameInput.clear()
37 | await passwordInput.clear()
38 |
39 | // 点击提交按钮触发 Ant Design 表单校验html-type="submit"
40 | await page.locator('button.login-btn').click()
41 |
42 | // Ant Design Vue 表单校验失败时会显示错误提示
43 | await expect(
44 | page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
45 | ).toBeVisible({ timeout: 5000 })
46 | })
47 |
48 | test('L-03 错误密码登录失败', async ({ page }) => {
49 | await page.goto(`/${TENANT_CODE}/login`)
50 |
51 | // 填写错误的用户名和密码
52 | await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
53 | await page.locator('input[type="password"]').fill('wrongpassword')
54 |
55 | // 点击登录
56 | await page.locator('button.login-btn').click()
57 |
58 | // 验证错误提示信息
59 | await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
60 | })
61 |
62 | test('L-04 正确凭据登录成功跳转', async ({ page }) => {
63 | await page.goto(`/${TENANT_CODE}/login`)
64 |
65 | // 填写正确的用户名和密码
66 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
67 | await page.locator('input[type="password"]').fill('admin123')
68 |
69 | // 点击登录
70 | await page.locator('button.login-btn').click()
71 |
72 | // 验证跳转到管理端页面(离开登录页)
73 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
74 |
75 | // 验证侧边栏可见(说明进入了管理端布局)
76 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
77 | })
78 |
79 | test('L-05 登录后 Token 存储正确', async ({ page }) => {
80 | await page.goto(`/${TENANT_CODE}/login`)
81 |
82 | // 填写并提交登录
83 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
84 | await page.locator('input[type="password"]').fill('admin123')
85 | await page.locator('button.login-btn').click()
86 |
87 | // 等待跳转
88 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
89 |
90 | // 验证 Cookie 中包含 token
91 | const cookies = await page.context().cookies()
92 | const tokenCookie = cookies.find((c) => c.name === 'token')
93 | expect(tokenCookie).toBeDefined()
94 | expect(tokenCookie!.value.length).toBeGreaterThan(0)
95 | })
96 |
97 | test('L-06 退出登录清除状态', async ({ page }) => {
98 | await page.goto(`/${TENANT_CODE}/login`)
99 |
100 | // 先登录
101 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
102 | await page.locator('input[type="password"]').fill('admin123')
103 | await page.locator('button.login-btn').click()
104 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
105 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
106 |
107 | // 点击用户头像区域
108 | await page.locator('.user-info').click()
109 |
110 | // 点击退出登录
111 | await page.locator('text=退出登录').click()
112 |
113 | // 验证跳转回登录页
114 | await page.waitForURL(/\/login/, { timeout: 10_000 })
115 | await expect(page.locator('.login-container')).toBeVisible()
116 | })
117 | })
118 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\reviews.spec.ts >> 评审管理 >> RV-03 评委管理页面
- Location: e2e\admin\reviews.spec.ts:46:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-05 返回按钮功能
- Location: e2e\admin\contest-create.spec.ts:85:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\users.spec.ts >> 用户管理 >> U-05 用户操作菜单
- Location: e2e\admin\users.spec.ts:88:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\navigation.spec.ts >> 侧边栏导航 >> N-04 浏览器刷新保持状态
- Location: e2e\admin\navigation.spec.ts:52:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\registrations.spec.ts >> 报名管理 >> R-03 审核状态筛选
- Location: e2e\admin\registrations.spec.ts:46:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,143 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\login.spec.ts >> 管理端登录流程 >> L-04 正确凭据登录成功跳转
- Location: e2e\admin\login.spec.ts:62:3
# Error details
```
TimeoutError: locator.fill: Timeout 10000ms exceeded.
Call log:
- waiting for locator('input[placeholder="请输入用户名"]')
```
# Test source
```ts
1 | import { test, expect } from '../fixtures/admin.fixture'
2 | import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
3 |
4 | /**
5 | * 登录流程测试
6 | * 测试管理端登录页面的各项功能
7 | */
8 |
9 | test.describe('管理端登录流程', () => {
10 | test.beforeEach(async ({ page }) => {
11 | await setupApiMocks(page)
12 | })
13 |
14 | test('L-01 管理端登录页正常渲染', async ({ page }) => {
15 | await page.goto(`/${TENANT_CODE}/login`)
16 |
17 | // 验证页面标题
18 | await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
19 |
20 | // 验证表单字段可见
21 | await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
22 | await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
23 |
24 | // 验证登录按钮可见
25 | await expect(page.locator('button.login-btn')).toBeVisible()
26 | // Ant Design 按钮文本可能有空格,使用正则匹配
27 | await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
28 | })
29 |
30 | test('L-02 空表单提交显示校验错误', async ({ page }) => {
31 | await page.goto(`/${TENANT_CODE}/login`)
32 |
33 | // 开发模式会自动填充 admin/admin123先清空字段
34 | const usernameInput = page.locator('input[placeholder="请输入用户名"]')
35 | const passwordInput = page.locator('input[type="password"]')
36 | await usernameInput.clear()
37 | await passwordInput.clear()
38 |
39 | // 点击提交按钮触发 Ant Design 表单校验html-type="submit"
40 | await page.locator('button.login-btn').click()
41 |
42 | // Ant Design Vue 表单校验失败时会显示错误提示
43 | await expect(
44 | page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
45 | ).toBeVisible({ timeout: 5000 })
46 | })
47 |
48 | test('L-03 错误密码登录失败', async ({ page }) => {
49 | await page.goto(`/${TENANT_CODE}/login`)
50 |
51 | // 填写错误的用户名和密码
52 | await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
53 | await page.locator('input[type="password"]').fill('wrongpassword')
54 |
55 | // 点击登录
56 | await page.locator('button.login-btn').click()
57 |
58 | // 验证错误提示信息
59 | await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
60 | })
61 |
62 | test('L-04 正确凭据登录成功跳转', async ({ page }) => {
63 | await page.goto(`/${TENANT_CODE}/login`)
64 |
65 | // 填写正确的用户名和密码
> 66 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
| ^ TimeoutError: locator.fill: Timeout 10000ms exceeded.
67 | await page.locator('input[type="password"]').fill('admin123')
68 |
69 | // 点击登录
70 | await page.locator('button.login-btn').click()
71 |
72 | // 验证跳转到管理端页面(离开登录页)
73 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
74 |
75 | // 验证侧边栏可见(说明进入了管理端布局)
76 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
77 | })
78 |
79 | test('L-05 登录后 Token 存储正确', async ({ page }) => {
80 | await page.goto(`/${TENANT_CODE}/login`)
81 |
82 | // 填写并提交登录
83 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
84 | await page.locator('input[type="password"]').fill('admin123')
85 | await page.locator('button.login-btn').click()
86 |
87 | // 等待跳转
88 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
89 |
90 | // 验证 Cookie 中包含 token
91 | const cookies = await page.context().cookies()
92 | const tokenCookie = cookies.find((c) => c.name === 'token')
93 | expect(tokenCookie).toBeDefined()
94 | expect(tokenCookie!.value.length).toBeGreaterThan(0)
95 | })
96 |
97 | test('L-06 退出登录清除状态', async ({ page }) => {
98 | await page.goto(`/${TENANT_CODE}/login`)
99 |
100 | // 先登录
101 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
102 | await page.locator('input[type="password"]').fill('admin123')
103 | await page.locator('button.login-btn').click()
104 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
105 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
106 |
107 | // 点击用户头像区域
108 | await page.locator('.user-info').click()
109 |
110 | // 点击退出登录
111 | await page.locator('text=退出登录').click()
112 |
113 | // 验证跳转回登录页
114 | await page.waitForURL(/\/login/, { timeout: 10_000 })
115 | await expect(page.locator('.login-container')).toBeVisible()
116 | })
117 | })
118 |
```

View File

@ -0,0 +1,143 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\login.spec.ts >> 管理端登录流程 >> L-03 错误密码登录失败
- Location: e2e\admin\login.spec.ts:48:3
# Error details
```
TimeoutError: locator.fill: Timeout 10000ms exceeded.
Call log:
- waiting for locator('input[placeholder="请输入用户名"]')
```
# Test source
```ts
1 | import { test, expect } from '../fixtures/admin.fixture'
2 | import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
3 |
4 | /**
5 | * 登录流程测试
6 | * 测试管理端登录页面的各项功能
7 | */
8 |
9 | test.describe('管理端登录流程', () => {
10 | test.beforeEach(async ({ page }) => {
11 | await setupApiMocks(page)
12 | })
13 |
14 | test('L-01 管理端登录页正常渲染', async ({ page }) => {
15 | await page.goto(`/${TENANT_CODE}/login`)
16 |
17 | // 验证页面标题
18 | await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
19 |
20 | // 验证表单字段可见
21 | await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
22 | await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
23 |
24 | // 验证登录按钮可见
25 | await expect(page.locator('button.login-btn')).toBeVisible()
26 | // Ant Design 按钮文本可能有空格,使用正则匹配
27 | await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
28 | })
29 |
30 | test('L-02 空表单提交显示校验错误', async ({ page }) => {
31 | await page.goto(`/${TENANT_CODE}/login`)
32 |
33 | // 开发模式会自动填充 admin/admin123先清空字段
34 | const usernameInput = page.locator('input[placeholder="请输入用户名"]')
35 | const passwordInput = page.locator('input[type="password"]')
36 | await usernameInput.clear()
37 | await passwordInput.clear()
38 |
39 | // 点击提交按钮触发 Ant Design 表单校验html-type="submit"
40 | await page.locator('button.login-btn').click()
41 |
42 | // Ant Design Vue 表单校验失败时会显示错误提示
43 | await expect(
44 | page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
45 | ).toBeVisible({ timeout: 5000 })
46 | })
47 |
48 | test('L-03 错误密码登录失败', async ({ page }) => {
49 | await page.goto(`/${TENANT_CODE}/login`)
50 |
51 | // 填写错误的用户名和密码
> 52 | await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
| ^ TimeoutError: locator.fill: Timeout 10000ms exceeded.
53 | await page.locator('input[type="password"]').fill('wrongpassword')
54 |
55 | // 点击登录
56 | await page.locator('button.login-btn').click()
57 |
58 | // 验证错误提示信息
59 | await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
60 | })
61 |
62 | test('L-04 正确凭据登录成功跳转', async ({ page }) => {
63 | await page.goto(`/${TENANT_CODE}/login`)
64 |
65 | // 填写正确的用户名和密码
66 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
67 | await page.locator('input[type="password"]').fill('admin123')
68 |
69 | // 点击登录
70 | await page.locator('button.login-btn').click()
71 |
72 | // 验证跳转到管理端页面(离开登录页)
73 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
74 |
75 | // 验证侧边栏可见(说明进入了管理端布局)
76 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
77 | })
78 |
79 | test('L-05 登录后 Token 存储正确', async ({ page }) => {
80 | await page.goto(`/${TENANT_CODE}/login`)
81 |
82 | // 填写并提交登录
83 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
84 | await page.locator('input[type="password"]').fill('admin123')
85 | await page.locator('button.login-btn').click()
86 |
87 | // 等待跳转
88 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
89 |
90 | // 验证 Cookie 中包含 token
91 | const cookies = await page.context().cookies()
92 | const tokenCookie = cookies.find((c) => c.name === 'token')
93 | expect(tokenCookie).toBeDefined()
94 | expect(tokenCookie!.value.length).toBeGreaterThan(0)
95 | })
96 |
97 | test('L-06 退出登录清除状态', async ({ page }) => {
98 | await page.goto(`/${TENANT_CODE}/login`)
99 |
100 | // 先登录
101 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
102 | await page.locator('input[type="password"]').fill('admin123')
103 | await page.locator('button.login-btn').click()
104 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
105 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
106 |
107 | // 点击用户头像区域
108 | await page.locator('.user-info').click()
109 |
110 | // 点击退出登录
111 | await page.locator('text=退出登录').click()
112 |
113 | // 验证跳转回登录页
114 | await page.waitForURL(/\/login/, { timeout: 10_000 })
115 | await expect(page.locator('.login-container')).toBeVisible()
116 | })
117 | })
118 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\navigation.spec.ts >> 侧边栏导航 >> N-03 菜单点击导航 - 活动管理子菜单
- Location: e2e\admin\navigation.spec.ts:33:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\dashboard.spec.ts >> 工作台/仪表盘 >> D-02 统计卡片数据展示
- Location: e2e\admin\dashboard.spec.ts:22:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contests.spec.ts >> 活动管理列表 >> C-04 分页功能正常
- Location: e2e\admin\contests.spec.ts:65:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,143 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\login.spec.ts >> 管理端登录流程 >> L-05 登录后 Token 存储正确
- Location: e2e\admin\login.spec.ts:79:3
# Error details
```
TimeoutError: locator.fill: Timeout 10000ms exceeded.
Call log:
- waiting for locator('input[placeholder="请输入用户名"]')
```
# Test source
```ts
1 | import { test, expect } from '../fixtures/admin.fixture'
2 | import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
3 |
4 | /**
5 | * 登录流程测试
6 | * 测试管理端登录页面的各项功能
7 | */
8 |
9 | test.describe('管理端登录流程', () => {
10 | test.beforeEach(async ({ page }) => {
11 | await setupApiMocks(page)
12 | })
13 |
14 | test('L-01 管理端登录页正常渲染', async ({ page }) => {
15 | await page.goto(`/${TENANT_CODE}/login`)
16 |
17 | // 验证页面标题
18 | await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
19 |
20 | // 验证表单字段可见
21 | await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
22 | await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
23 |
24 | // 验证登录按钮可见
25 | await expect(page.locator('button.login-btn')).toBeVisible()
26 | // Ant Design 按钮文本可能有空格,使用正则匹配
27 | await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
28 | })
29 |
30 | test('L-02 空表单提交显示校验错误', async ({ page }) => {
31 | await page.goto(`/${TENANT_CODE}/login`)
32 |
33 | // 开发模式会自动填充 admin/admin123先清空字段
34 | const usernameInput = page.locator('input[placeholder="请输入用户名"]')
35 | const passwordInput = page.locator('input[type="password"]')
36 | await usernameInput.clear()
37 | await passwordInput.clear()
38 |
39 | // 点击提交按钮触发 Ant Design 表单校验html-type="submit"
40 | await page.locator('button.login-btn').click()
41 |
42 | // Ant Design Vue 表单校验失败时会显示错误提示
43 | await expect(
44 | page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
45 | ).toBeVisible({ timeout: 5000 })
46 | })
47 |
48 | test('L-03 错误密码登录失败', async ({ page }) => {
49 | await page.goto(`/${TENANT_CODE}/login`)
50 |
51 | // 填写错误的用户名和密码
52 | await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
53 | await page.locator('input[type="password"]').fill('wrongpassword')
54 |
55 | // 点击登录
56 | await page.locator('button.login-btn').click()
57 |
58 | // 验证错误提示信息
59 | await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
60 | })
61 |
62 | test('L-04 正确凭据登录成功跳转', async ({ page }) => {
63 | await page.goto(`/${TENANT_CODE}/login`)
64 |
65 | // 填写正确的用户名和密码
66 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
67 | await page.locator('input[type="password"]').fill('admin123')
68 |
69 | // 点击登录
70 | await page.locator('button.login-btn').click()
71 |
72 | // 验证跳转到管理端页面(离开登录页)
73 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
74 |
75 | // 验证侧边栏可见(说明进入了管理端布局)
76 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
77 | })
78 |
79 | test('L-05 登录后 Token 存储正确', async ({ page }) => {
80 | await page.goto(`/${TENANT_CODE}/login`)
81 |
82 | // 填写并提交登录
> 83 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
| ^ TimeoutError: locator.fill: Timeout 10000ms exceeded.
84 | await page.locator('input[type="password"]').fill('admin123')
85 | await page.locator('button.login-btn').click()
86 |
87 | // 等待跳转
88 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
89 |
90 | // 验证 Cookie 中包含 token
91 | const cookies = await page.context().cookies()
92 | const tokenCookie = cookies.find((c) => c.name === 'token')
93 | expect(tokenCookie).toBeDefined()
94 | expect(tokenCookie!.value.length).toBeGreaterThan(0)
95 | })
96 |
97 | test('L-06 退出登录清除状态', async ({ page }) => {
98 | await page.goto(`/${TENANT_CODE}/login`)
99 |
100 | // 先登录
101 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
102 | await page.locator('input[type="password"]').fill('admin123')
103 | await page.locator('button.login-btn').click()
104 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
105 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
106 |
107 | // 点击用户头像区域
108 | await page.locator('.user-info').click()
109 |
110 | // 点击退出登录
111 | await page.locator('text=退出登录').click()
112 |
113 | // 验证跳转回登录页
114 | await page.waitForURL(/\/login/, { timeout: 10_000 })
115 | await expect(page.locator('.login-container')).toBeVisible()
116 | })
117 | })
118 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-03 填写活动信息
- Location: e2e\admin\contest-create.spec.ts:55:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\users.spec.ts >> 用户管理 >> U-02 搜索用户
- Location: e2e\admin\users.spec.ts:30:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,143 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\login.spec.ts >> 管理端登录流程 >> L-06 退出登录清除状态
- Location: e2e\admin\login.spec.ts:97:3
# Error details
```
TimeoutError: locator.fill: Timeout 10000ms exceeded.
Call log:
- waiting for locator('input[placeholder="请输入用户名"]')
```
# Test source
```ts
1 | import { test, expect } from '../fixtures/admin.fixture'
2 | import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
3 |
4 | /**
5 | * 登录流程测试
6 | * 测试管理端登录页面的各项功能
7 | */
8 |
9 | test.describe('管理端登录流程', () => {
10 | test.beforeEach(async ({ page }) => {
11 | await setupApiMocks(page)
12 | })
13 |
14 | test('L-01 管理端登录页正常渲染', async ({ page }) => {
15 | await page.goto(`/${TENANT_CODE}/login`)
16 |
17 | // 验证页面标题
18 | await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
19 |
20 | // 验证表单字段可见
21 | await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
22 | await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
23 |
24 | // 验证登录按钮可见
25 | await expect(page.locator('button.login-btn')).toBeVisible()
26 | // Ant Design 按钮文本可能有空格,使用正则匹配
27 | await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
28 | })
29 |
30 | test('L-02 空表单提交显示校验错误', async ({ page }) => {
31 | await page.goto(`/${TENANT_CODE}/login`)
32 |
33 | // 开发模式会自动填充 admin/admin123先清空字段
34 | const usernameInput = page.locator('input[placeholder="请输入用户名"]')
35 | const passwordInput = page.locator('input[type="password"]')
36 | await usernameInput.clear()
37 | await passwordInput.clear()
38 |
39 | // 点击提交按钮触发 Ant Design 表单校验html-type="submit"
40 | await page.locator('button.login-btn').click()
41 |
42 | // Ant Design Vue 表单校验失败时会显示错误提示
43 | await expect(
44 | page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
45 | ).toBeVisible({ timeout: 5000 })
46 | })
47 |
48 | test('L-03 错误密码登录失败', async ({ page }) => {
49 | await page.goto(`/${TENANT_CODE}/login`)
50 |
51 | // 填写错误的用户名和密码
52 | await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
53 | await page.locator('input[type="password"]').fill('wrongpassword')
54 |
55 | // 点击登录
56 | await page.locator('button.login-btn').click()
57 |
58 | // 验证错误提示信息
59 | await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
60 | })
61 |
62 | test('L-04 正确凭据登录成功跳转', async ({ page }) => {
63 | await page.goto(`/${TENANT_CODE}/login`)
64 |
65 | // 填写正确的用户名和密码
66 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
67 | await page.locator('input[type="password"]').fill('admin123')
68 |
69 | // 点击登录
70 | await page.locator('button.login-btn').click()
71 |
72 | // 验证跳转到管理端页面(离开登录页)
73 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
74 |
75 | // 验证侧边栏可见(说明进入了管理端布局)
76 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
77 | })
78 |
79 | test('L-05 登录后 Token 存储正确', async ({ page }) => {
80 | await page.goto(`/${TENANT_CODE}/login`)
81 |
82 | // 填写并提交登录
83 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
84 | await page.locator('input[type="password"]').fill('admin123')
85 | await page.locator('button.login-btn').click()
86 |
87 | // 等待跳转
88 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
89 |
90 | // 验证 Cookie 中包含 token
91 | const cookies = await page.context().cookies()
92 | const tokenCookie = cookies.find((c) => c.name === 'token')
93 | expect(tokenCookie).toBeDefined()
94 | expect(tokenCookie!.value.length).toBeGreaterThan(0)
95 | })
96 |
97 | test('L-06 退出登录清除状态', async ({ page }) => {
98 | await page.goto(`/${TENANT_CODE}/login`)
99 |
100 | // 先登录
> 101 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
| ^ TimeoutError: locator.fill: Timeout 10000ms exceeded.
102 | await page.locator('input[type="password"]').fill('admin123')
103 | await page.locator('button.login-btn').click()
104 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
105 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
106 |
107 | // 点击用户头像区域
108 | await page.locator('.user-info').click()
109 |
110 | // 点击退出登录
111 | await page.locator('text=退出登录').click()
112 |
113 | // 验证跳转回登录页
114 | await page.waitForURL(/\/login/, { timeout: 10_000 })
115 | await expect(page.locator('.login-container')).toBeVisible()
116 | })
117 | })
118 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contests.spec.ts >> 活动管理列表 >> C-03 活动阶段筛选正常
- Location: e2e\admin\contests.spec.ts:51:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\dashboard.spec.ts >> 工作台/仪表盘 >> D-03 快捷入口可点击
- Location: e2e\admin\dashboard.spec.ts:46:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-02 必填字段校验
- Location: e2e\admin\contest-create.spec.ts:35:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\registrations.spec.ts >> 报名管理 >> R-01 报名列表页正常加载
- Location: e2e\admin\registrations.spec.ts:19:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\dashboard.spec.ts >> 工作台/仪表盘 >> D-01 工作台页面正常加载
- Location: e2e\admin\dashboard.spec.ts:8:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\works.spec.ts >> 作品管理 >> W-01 作品列表页正常加载
- Location: e2e\admin\works.spec.ts:19:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,143 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\login.spec.ts >> 管理端登录流程 >> L-02 空表单提交显示校验错误
- Location: e2e\admin\login.spec.ts:30:3
# Error details
```
TimeoutError: locator.clear: Timeout 10000ms exceeded.
Call log:
- waiting for locator('input[placeholder="请输入用户名"]')
```
# Test source
```ts
1 | import { test, expect } from '../fixtures/admin.fixture'
2 | import { setupApiMocks, TENANT_CODE, MOCK_TOKEN } from '../fixtures/admin.fixture'
3 |
4 | /**
5 | * 登录流程测试
6 | * 测试管理端登录页面的各项功能
7 | */
8 |
9 | test.describe('管理端登录流程', () => {
10 | test.beforeEach(async ({ page }) => {
11 | await setupApiMocks(page)
12 | })
13 |
14 | test('L-01 管理端登录页正常渲染', async ({ page }) => {
15 | await page.goto(`/${TENANT_CODE}/login`)
16 |
17 | // 验证页面标题
18 | await expect(page.locator('.login-header h2')).toHaveText('乐绘世界创想活动乐园')
19 |
20 | // 验证表单字段可见
21 | await expect(page.locator('input[placeholder="请输入用户名"]')).toBeVisible()
22 | await expect(page.locator('input[placeholder="请输入密码"]')).toBeVisible()
23 |
24 | // 验证登录按钮可见
25 | await expect(page.locator('button.login-btn')).toBeVisible()
26 | // Ant Design 按钮文本可能有空格,使用正则匹配
27 | await expect(page.locator('button.login-btn')).toHaveText(/登\s*录/)
28 | })
29 |
30 | test('L-02 空表单提交显示校验错误', async ({ page }) => {
31 | await page.goto(`/${TENANT_CODE}/login`)
32 |
33 | // 开发模式会自动填充 admin/admin123先清空字段
34 | const usernameInput = page.locator('input[placeholder="请输入用户名"]')
35 | const passwordInput = page.locator('input[type="password"]')
> 36 | await usernameInput.clear()
| ^ TimeoutError: locator.clear: Timeout 10000ms exceeded.
37 | await passwordInput.clear()
38 |
39 | // 点击提交按钮触发 Ant Design 表单校验html-type="submit"
40 | await page.locator('button.login-btn').click()
41 |
42 | // Ant Design Vue 表单校验失败时会显示错误提示
43 | await expect(
44 | page.locator('.ant-form-item-explain-error, .ant-form-item-explain, .ant-form-item-with-help, .has-error').first()
45 | ).toBeVisible({ timeout: 5000 })
46 | })
47 |
48 | test('L-03 错误密码登录失败', async ({ page }) => {
49 | await page.goto(`/${TENANT_CODE}/login`)
50 |
51 | // 填写错误的用户名和密码
52 | await page.locator('input[placeholder="请输入用户名"]').fill('wrong')
53 | await page.locator('input[type="password"]').fill('wrongpassword')
54 |
55 | // 点击登录
56 | await page.locator('button.login-btn').click()
57 |
58 | // 验证错误提示信息
59 | await expect(page.locator('.ant-message')).toBeVisible({ timeout: 5000 })
60 | })
61 |
62 | test('L-04 正确凭据登录成功跳转', async ({ page }) => {
63 | await page.goto(`/${TENANT_CODE}/login`)
64 |
65 | // 填写正确的用户名和密码
66 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
67 | await page.locator('input[type="password"]').fill('admin123')
68 |
69 | // 点击登录
70 | await page.locator('button.login-btn').click()
71 |
72 | // 验证跳转到管理端页面(离开登录页)
73 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
74 |
75 | // 验证侧边栏可见(说明进入了管理端布局)
76 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
77 | })
78 |
79 | test('L-05 登录后 Token 存储正确', async ({ page }) => {
80 | await page.goto(`/${TENANT_CODE}/login`)
81 |
82 | // 填写并提交登录
83 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
84 | await page.locator('input[type="password"]').fill('admin123')
85 | await page.locator('button.login-btn').click()
86 |
87 | // 等待跳转
88 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
89 |
90 | // 验证 Cookie 中包含 token
91 | const cookies = await page.context().cookies()
92 | const tokenCookie = cookies.find((c) => c.name === 'token')
93 | expect(tokenCookie).toBeDefined()
94 | expect(tokenCookie!.value.length).toBeGreaterThan(0)
95 | })
96 |
97 | test('L-06 退出登录清除状态', async ({ page }) => {
98 | await page.goto(`/${TENANT_CODE}/login`)
99 |
100 | // 先登录
101 | await page.locator('input[placeholder="请输入用户名"]').fill('admin')
102 | await page.locator('input[type="password"]').fill('admin123')
103 | await page.locator('button.login-btn').click()
104 | await page.waitForURL((url) => !url.pathname.includes('/login'), { timeout: 15_000 })
105 | await expect(page.locator('.custom-sider')).toBeVisible({ timeout: 10_000 })
106 |
107 | // 点击用户头像区域
108 | await page.locator('.user-info').click()
109 |
110 | // 点击退出登录
111 | await page.locator('text=退出登录').click()
112 |
113 | // 验证跳转回登录页
114 | await page.waitForURL(/\/login/, { timeout: 10_000 })
115 | await expect(page.locator('.login-container')).toBeVisible()
116 | })
117 | })
118 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\works.spec.ts >> 作品管理 >> W-03 作品状态筛选
- Location: e2e\admin\works.spec.ts:46:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\users.spec.ts >> 用户管理 >> U-04 创建用户弹窗
- Location: e2e\admin\users.spec.ts:68:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contests.spec.ts >> 活动管理列表 >> C-01 活动列表页正常加载
- Location: e2e\admin\contests.spec.ts:19:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\navigation.spec.ts >> 侧边栏导航 >> N-02 菜单点击导航 - 工作台
- Location: e2e\admin\navigation.spec.ts:20:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\dashboard.spec.ts >> 工作台/仪表盘 >> D-04 顶部信息栏正确
- Location: e2e\admin\dashboard.spec.ts:68:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\users.spec.ts >> 用户管理 >> U-01 用户列表页正常加载
- Location: e2e\admin\users.spec.ts:23:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contests.spec.ts >> 活动管理列表 >> C-02 搜索功能正常
- Location: e2e\admin\contests.spec.ts:34:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\reviews.spec.ts >> 评审管理 >> RV-02 新建评审规则弹窗
- Location: e2e\admin\reviews.spec.ts:26:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\works.spec.ts >> 作品管理 >> W-02 搜索作品
- Location: e2e\admin\works.spec.ts:26:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

File diff suppressed because one or more lines are too long

View File

@ -10,11 +10,37 @@ const publicApi = axios.create({
publicApi.interceptors.request.use((config) => { publicApi.interceptors.request.use((config) => {
const token = localStorage.getItem("public_token") const token = localStorage.getItem("public_token")
if (token) { if (token) {
// 检查 Token 是否过期
if (isTokenExpired(token)) {
localStorage.removeItem("public_token")
localStorage.removeItem("public_user")
// 如果在公众端页面,跳转到登录页
if (window.location.pathname.startsWith("/p/")) {
window.location.href = "/p/login"
}
return config
}
config.headers.Authorization = `Bearer ${token}` config.headers.Authorization = `Bearer ${token}`
} }
return config return config
}) })
/**
* JWT payload Token
*/
function isTokenExpired(token: string): boolean {
try {
const parts = token.split(".")
if (parts.length !== 3) return true
const payload = JSON.parse(atob(parts[1]))
if (!payload.exp) return false
// exp 是秒级时间戳,转换为毫秒比较
return Date.now() >= payload.exp * 1000
} catch {
return true
}
}
// 响应拦截器 // 响应拦截器
publicApi.interceptors.response.use( publicApi.interceptors.response.use(
(response) => { (response) => {

View File

@ -10,36 +10,14 @@
<!-- 桌面端导航菜单 --> <!-- 桌面端导航菜单 -->
<nav class="header-nav"> <nav class="header-nav">
<div <div
v-for="item in desktopNavItems"
:key="item.key"
class="nav-item" class="nav-item"
:class="{ active: currentTab === 'home' }" :class="{ active: currentTab === item.key }"
@click="goHome" @click="handleNavClick(item)"
> >
<home-outlined /> <component :is="item.icon" />
<span>发现</span> <span>{{ item.label }}</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'activity' }"
@click="goActivity"
>
<trophy-outlined />
<span>活动</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'create' }"
@click="goCreate"
>
<plus-circle-outlined />
<span>创作</span>
</div>
<div
class="nav-item"
:class="{ active: currentTab === 'works' }"
@click="goWorks"
>
<appstore-outlined />
<span>作品库</span>
</div> </div>
</nav> </nav>
@ -73,36 +51,14 @@
<!-- 移动端底部导航 --> <!-- 移动端底部导航 -->
<nav class="public-tabbar"> <nav class="public-tabbar">
<div <div
v-for="item in mobileNavItems"
:key="item.key"
class="tabbar-item" class="tabbar-item"
:class="{ active: currentTab === 'home' }" :class="{ active: currentTab === item.key }"
@click="goHome" @click="handleNavClick(item)"
> >
<home-outlined /> <component :is="item.icon" />
<span>发现</span> <span>{{ item.label }}</span>
</div>
<div
class="tabbar-item"
:class="{ active: currentTab === 'create' }"
@click="goCreate"
>
<plus-circle-outlined />
<span>创作</span>
</div>
<div
class="tabbar-item"
:class="{ active: currentTab === 'activity' }"
@click="goActivity"
>
<trophy-outlined />
<span>活动</span>
</div>
<div
class="tabbar-item"
:class="{ active: currentTab === 'works' }"
@click="goWorks"
>
<appstore-outlined />
<span>作品库</span>
</div> </div>
<div <div
class="tabbar-item" class="tabbar-item"
@ -122,6 +78,29 @@ import { useRouter, useRoute } from "vue-router"
import { HomeOutlined, UserOutlined, PlusCircleOutlined, AppstoreOutlined, TrophyOutlined } from "@ant-design/icons-vue" import { HomeOutlined, UserOutlined, PlusCircleOutlined, AppstoreOutlined, TrophyOutlined } from "@ant-design/icons-vue"
import { useAicreateStore } from "@/stores/aicreate" import { useAicreateStore } from "@/stores/aicreate"
//
interface NavItem {
key: string
label: string
path: string
requireAuth: boolean
icon: any
}
const desktopNavItems: NavItem[] = [
{ key: 'home', label: '发现', path: '/p/gallery', requireAuth: false, icon: HomeOutlined },
{ key: 'activity', label: '活动', path: '/p/activities', requireAuth: false, icon: TrophyOutlined },
{ key: 'create', label: '创作', path: '/p/create', requireAuth: true, icon: PlusCircleOutlined },
{ key: 'works', label: '作品库', path: '/p/works', requireAuth: true, icon: AppstoreOutlined },
]
const mobileNavItems: NavItem[] = [
{ key: 'home', label: '发现', path: '/p/gallery', requireAuth: false, icon: HomeOutlined },
{ key: 'create', label: '创作', path: '/p/create', requireAuth: true, icon: PlusCircleOutlined },
{ key: 'activity', label: '活动', path: '/p/activities', requireAuth: false, icon: TrophyOutlined },
{ key: 'works', label: '作品库', path: '/p/works', requireAuth: true, icon: AppstoreOutlined },
]
const router = useRouter() const router = useRouter()
const route = useRoute() const route = useRoute()
const aicreateStore = useAicreateStore() const aicreateStore = useAicreateStore()
@ -142,22 +121,25 @@ const currentTab = computed(() => {
return "home" return "home"
}) })
const goHome = () => router.push("/p/gallery") const handleNavClick = (item: NavItem) => {
const goActivity = () => router.push("/p/activities") if (item.requireAuth && !isLoggedIn.value) {
const goCreate = () => { router.push("/p/login")
if (!isLoggedIn.value) { router.push("/p/login"); return } return
}
//
if (item.key === 'create') {
const saved = aicreateStore.lastCreateRoute const saved = aicreateStore.lastCreateRoute
//
if (saved && saved !== '/p/create') { if (saved && saved !== '/p/create') {
router.push(saved) router.push(saved)
} else { } else {
router.push("/p/create") router.push(item.path)
} }
return
} }
const goWorks = () => { router.push(item.path)
if (!isLoggedIn.value) { router.push("/p/login"); return }
router.push("/p/works")
} }
const goHome = () => router.push("/p/gallery")
const goMine = () => { const goMine = () => {
if (!isLoggedIn.value) { router.push("/p/login"); return } if (!isLoggedIn.value) { router.push("/p/login"); return }
router.push("/p/mine") router.push("/p/mine")

View File

@ -493,406 +493,254 @@ router.beforeEach(async (to, _from, next) => {
} }
const authStore = useAuthStore() const authStore = useAuthStore()
// 从URL中提取租户编码
const tenantCodeFromUrl = extractTenantCodeFromPath(to.path) const tenantCodeFromUrl = extractTenantCodeFromPath(to.path)
// 如果 token 存在但用户信息不存在,先获取用户信息 // ─── 1. 已登录但无用户信息:首次加载,尝试获取 ───
if (authStore.token && !authStore.user) { if (authStore.token && !authStore.user) {
try { await handleFirstLoad(to, authStore, tenantCodeFromUrl, next)
const userInfo = await authStore.fetchUserInfo()
// 如果获取用户信息失败或用户信息为空,跳转到登录页
if (!userInfo) {
authStore.logout()
const tenantCode =
tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
if (tenantCode) {
next({
path: `/${tenantCode}/login`,
query: { redirect: to.fullPath },
})
} else {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
}
return return
} }
// 获取用户信息后,检查租户编码一致性 // ─── 2. 无 Token 但需要认证:跳转登录 ───
const userTenantCode = userInfo?.tenantCode if (!authStore.token && to.meta.requiresAuth !== false) {
if (userTenantCode) { redirectToLogin(to, tenantCodeFromUrl, next)
// 如果URL中的租户编码与用户信息不一致更正URL
if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) {
const correctedPath = buildPathWithTenantCode(
userTenantCode,
to.path.replace(`/${tenantCodeFromUrl}`, "")
)
next({ path: correctedPath, query: to.query, replace: true })
return return
} }
// 如果URL中没有租户编码添加租户编码
if (!tenantCodeFromUrl) { // ─── 3. 已登录:检查租户编码一致性 ───
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path) if (authStore.isAuthenticated && authStore.user) {
next({ path: correctedPath, query: to.query, replace: true }) const corrected = checkTenantCodeConsistency(authStore, tenantCodeFromUrl, to)
if (corrected) {
next({ path: corrected, query: to.query, replace: true })
return return
} }
} }
// 获取用户信息后,添加动态路由并等待生效
// ─── 4. 已登录且有菜单:添加动态路由 ───
if (authStore.isAuthenticated && authStore.menus.length > 0) {
const wasAdded = dynamicRoutesAdded
await addDynamicRoutes() await addDynamicRoutes()
// 保存原始目标路径
const targetPath = to.fullPath
// 路由已生效,重新解析目标路由
const resolved = router.resolve(targetPath)
// 如果目标是租户根路径(如 /judge、/super直接跳转到第一个菜单 // 首次添加路由后需要重新导航
const isRootPath = to.matched.length === 1 && to.matched[0].name === "Main" if (!wasAdded && dynamicRoutesAdded) {
if (isRootPath && authStore.menus?.length) { await handleRoutesFirstAdded(to, authStore, tenantCodeFromUrl, next)
const findFirst = (menus: any[]): string | null => { return
for (const m of menus) { }
if (m.path && m.component) return m.path.startsWith("/") ? m.path.slice(1) : m.path }
if (m.children?.length) { const c = findFirst(m.children); if (c) return c }
// ─── 5. 路由已加载:检查是否需要重定向到第一个菜单 ───
if (authStore.isAuthenticated && dynamicRoutesAdded && authStore.menus.length > 0) {
const isMainRoute = to.name === "Main" ||
(to.matched.length === 1 && to.matched[0].name === "Main")
const resolved = router.resolve(to.fullPath)
if ((resolved.name === "NotFound" || isMainRoute) &&
to.name !== "Login" && to.name !== "LoginFallback") {
const firstPath = findFirstMenuPath(authStore.menus)
const tc = tenantCodeFromUrl || authStore.user?.tenantCode
if (firstPath && tc) {
next({ path: `/${tc}/${firstPath}`, replace: true })
return
}
}
}
// ─── 6. 需要认证的路由:二次检查 token 和用户信息 ───
if (to.meta.requiresAuth !== false) {
if (!authStore.token || !authStore.user) {
redirectToLogin(to, tenantCodeFromUrl, next)
return
}
}
// ─── 7. 已登录访问登录页:重定向到首页 ───
if ((to.name === "Login" || to.name === "LoginFallback") && authStore.isAuthenticated) {
if (!dynamicRoutesAdded && authStore.menus.length > 0) {
await addDynamicRoutes()
}
const userTenantCode = authStore.user?.tenantCode || "default"
next({ path: `/${userTenantCode}` })
return
}
// ─── 8. LoginFallback 无租户编码时尝试补全 ───
if (to.name === "LoginFallback" && !tenantCodeFromUrl) {
if (authStore.isAuthenticated && authStore.user?.tenantCode) {
next({ path: `/${authStore.user.tenantCode}/login`, replace: true })
return
}
next()
return
}
// ─── 9. 检查角色和权限 ───
if (to.meta.roles?.length && !authStore.hasAnyRole(to.meta.roles)) {
next({ name: "Forbidden" })
return
}
if (to.meta.permissions?.length && !authStore.hasAnyPermission(to.meta.permissions)) {
next({ name: "Forbidden" })
return
}
next()
})
// ==================== 路由守卫辅助函数 ====================
/**
*
*/
function findFirstMenuPath(menus: any[]): string | null {
for (const menu of menus) {
if (menu.path && menu.component) {
return menu.path.startsWith("/") ? menu.path.slice(1) : menu.path
}
if (menu.children?.length) {
const childPath = findFirstMenuPath(menu.children)
if (childPath) return childPath
}
} }
return null return null
} }
const first = findFirst(authStore.menus)
/**
*
*/
function redirectToLogin(to: any, tenantCode: string | null, next: any) {
if (tenantCode) {
next({ path: `/${tenantCode}/login`, query: { redirect: to.fullPath } })
} else {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
}
}
/**
* null
*/
function checkTenantCodeConsistency(
authStore: ReturnType<typeof useAuthStore>,
tenantCodeFromUrl: string | null,
to: any,
): string | null {
const userTenantCode = authStore.user?.tenantCode
if (!userTenantCode) return null
// URL 中的租户编码与用户信息不一致
if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) {
return buildPathWithTenantCode(userTenantCode, to.path.replace(`/${tenantCodeFromUrl}`, ""))
}
// URL 中缺少租户编码(排除特殊路径)
const skipPaths = ["/login", "/403"]
if (!tenantCodeFromUrl && !skipPaths.some(p => to.path.startsWith(p))) {
return buildPathWithTenantCode(userTenantCode, to.path)
}
return null
}
/**
* Token
*/
async function handleFirstLoad(
to: any,
authStore: ReturnType<typeof useAuthStore>,
tenantCodeFromUrl: string | null,
next: any,
) {
try {
const userInfo = await authStore.fetchUserInfo()
if (!userInfo) {
authStore.logout()
redirectToLogin(to, tenantCodeFromUrl, next)
return
}
// 检查租户编码一致性
const userTenantCode = userInfo.tenantCode
if (userTenantCode) {
if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) {
const corrected = buildPathWithTenantCode(
userTenantCode, to.path.replace(`/${tenantCodeFromUrl}`, ""))
next({ path: corrected, query: to.query, replace: true })
return
}
if (!tenantCodeFromUrl) {
const corrected = buildPathWithTenantCode(userTenantCode, to.path)
next({ path: corrected, query: to.query, replace: true })
return
}
}
// 添加动态路由
await addDynamicRoutes()
const targetPath = to.fullPath
const resolved = router.resolve(targetPath)
// 如果目标是租户根路径,跳转到第一个菜单
const isRootPath = to.matched.length === 1 && to.matched[0].name === "Main"
if (isRootPath && authStore.menus?.length) {
const first = findFirstMenuPath(authStore.menus)
if (first) { if (first) {
const tc = tenantCodeFromUrl || authStore.user?.tenantCode const tc = tenantCodeFromUrl || authStore.user?.tenantCode
if (tc) { next({ path: `/${tc}/${first}`, replace: true }); return } if (tc) { next({ path: `/${tc}/${first}`, replace: true }); return }
} }
} }
// 如果解析后的路由不是404说明路由存在重新导航 // 路由存在则重新导航
if (resolved.name !== "NotFound") { if (resolved.name !== "NotFound") {
next({ path: targetPath, replace: true }) next({ path: targetPath, replace: true })
} else if (authStore.menus?.length) {
const firstPath = findFirstMenuPath(authStore.menus)
const tc = tenantCodeFromUrl || authStore.user?.tenantCode
if (firstPath && tc) {
next({ path: `/${tc}/${firstPath}`, replace: true })
} else { } else {
// 如果路由不存在,尝试重定向到用户第一个菜单 next()
if (authStore.menus && authStore.menus.length > 0) {
const findFirstMenuPath = (menus: any[]): string | null => {
for (const menu of menus) {
if (menu.path && menu.component) {
// 移除开头的斜杠
return menu.path.startsWith("/")
? menu.path.slice(1)
: menu.path
} }
if (menu.children && menu.children.length > 0) { } else if (to.meta.requiresAuth === false) {
const childPath = findFirstMenuPath(menu.children)
if (childPath) return childPath
}
}
return null
}
const firstMenuPath = findFirstMenuPath(authStore.menus)
if (firstMenuPath) {
const user = authStore.user as { tenantCode?: string } | null
const userTenantCode = user?.tenantCode
const tenantCode =
tenantCodeFromUrl ||
extractTenantCodeFromPath(to.path) ||
userTenantCode
if (tenantCode) {
next({ path: `/${tenantCode}/${firstMenuPath}`, replace: true })
return
}
}
}
// 如果路由不存在但需要认证跳转到登录页而不是404
if (to.meta.requiresAuth === false) {
// 路由确实不存在允许继续会显示404页面
next() next()
} else { } else {
const tenantCode = redirectToLogin(to, tenantCodeFromUrl, next)
tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
if (tenantCode) {
next({
path: `/${tenantCode}/login`,
query: { redirect: to.fullPath },
})
} else {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
} }
}
}
return
} catch (error) { } catch (error) {
// 获取失败,清除 token 并跳转到登录页
console.error("获取用户信息失败:", error) console.error("获取用户信息失败:", error)
authStore.logout() authStore.logout()
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path) redirectToLogin(to, tenantCodeFromUrl, next)
if (tenantCode) {
next({
path: `/${tenantCode}/login`,
query: { redirect: to.fullPath },
})
} else {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
}
return
} }
} }
// 如果 token 不存在,但需要认证,跳转到登录页 /**
if (!authStore.token && to.meta.requiresAuth !== false) { *
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path) */
if (tenantCode) { async function handleRoutesFirstAdded(
next({ to: any,
path: `/${tenantCode}/login`, authStore: ReturnType<typeof useAuthStore>,
query: { redirect: to.fullPath }, tenantCodeFromUrl: string | null,
}) next: any,
} else { ) {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
}
return
}
// 如果已登录,检查租户编码一致性
if (authStore.isAuthenticated && authStore.user) {
const userTenantCode = authStore.user.tenantCode
if (userTenantCode) {
// 如果URL中的租户编码与用户信息不一致更正URL
if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) {
const correctedPath = buildPathWithTenantCode(
userTenantCode,
to.path.replace(`/${tenantCodeFromUrl}`, "")
)
next({ path: correctedPath, query: to.query, replace: true })
return
}
// 如果URL中没有租户编码添加租户编码排除不需要认证的特殊路由
const skipTenantCodePaths = ["/login", "/403"]
const shouldSkipTenantCode = skipTenantCodePaths.some(p => to.path.startsWith(p))
if (!tenantCodeFromUrl && !shouldSkipTenantCode) {
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
next({ path: correctedPath, query: to.query, replace: true })
return
}
}
}
// 如果已登录且有菜单数据,添加或更新动态路由
if (authStore.isAuthenticated && authStore.menus.length > 0) {
// 保存添加路由前的状态
const wasRoutesAdded = dynamicRoutesAdded
// 添加或更新动态路由
await addDynamicRoutes()
// 如果这是第一次添加路由,需要重新导航
if (!wasRoutesAdded && dynamicRoutesAdded) {
// 等待路由完全生效
await nextTick() await nextTick()
await nextTick() await nextTick()
// 保存原始目标路径
const targetPath = to.fullPath const targetPath = to.fullPath
// 路由已生效,重新解析目标路由
const resolved = router.resolve(targetPath) const resolved = router.resolve(targetPath)
// 如果访问的是主路由,重定向到第一个菜单
const isMainRoute = to.name === "Main" const isMainRoute = to.name === "Main"
console.log('Route guard debug:', {
targetPath,
resolvedName: resolved.name,
resolvedPath: resolved.path,
isMainRoute,
toName: to.name,
toPath: to.path,
})
// 如果解析后的路由不是404说明路由存在重新导航
if (resolved.name !== "NotFound" && !isMainRoute) { if (resolved.name !== "NotFound" && !isMainRoute) {
next({ path: targetPath, replace: true }) next({ path: targetPath, replace: true })
return return
} }
// 如果路由不存在或是主路由,尝试重定向到用户第一个菜单 // 重定向到第一个菜单
if (authStore.menus && authStore.menus.length > 0) { const firstPath = authStore.menus ? findFirstMenuPath(authStore.menus) : null
const findFirstMenuPath = (menus: any[]): string | null => { const tc = tenantCodeFromUrl || authStore.user?.tenantCode
for (const menu of menus) { if (firstPath && tc) {
if (menu.path && menu.component) {
// 移除开头的斜杠
return menu.path.startsWith("/") ? menu.path.slice(1) : menu.path
}
if (menu.children && menu.children.length > 0) {
const childPath = findFirstMenuPath(menu.children)
if (childPath) return childPath
}
}
return null
}
const firstMenuPath = findFirstMenuPath(authStore.menus)
if (firstMenuPath) {
const userTenantCode = authStore.user
? (authStore.user.tenantCode as string | undefined)
: undefined
const tenantCode =
tenantCodeFromUrl ||
extractTenantCodeFromPath(to.path) ||
userTenantCode
if (tenantCode) {
// 再次等待,确保路由完全注册
await nextTick() await nextTick()
next({ path: `/${tenantCode}/${firstMenuPath}`, replace: true }) next({ path: `/${tc}/${firstPath}`, replace: true })
return
}
}
}
// 如果没有任何菜单跳转到404页面
const tenantCodeFor404 =
tenantCodeFromUrl ||
extractTenantCodeFromPath(to.path) ||
(authStore.user
? (authStore.user.tenantCode as string | undefined)
: undefined)
if (tenantCodeFor404) {
next({ path: `/${tenantCodeFor404}/404`, replace: true })
} else { } else {
next({ name: "NotFound" }) next({ name: "NotFound" })
} }
return
} }
}
// 如果已登录且有菜单,但路由已添加,检查当前路由是否存在
if (
authStore.isAuthenticated &&
authStore.menus.length > 0 &&
dynamicRoutesAdded
) {
const resolved = router.resolve(to.fullPath)
// 如果访问的是 Main 路由(无具体子路径)或路由不存在,重定向到用户第一个菜单
const isMainRouteWithoutChild = to.name === "Main" || to.matched.length === 1 && to.matched[0].name === "Main"
if (
(resolved.name === "NotFound" || isMainRouteWithoutChild) &&
to.name !== "Login" &&
to.name !== "LoginFallback"
) {
const findFirstMenuPath = (menus: any[]): string | null => {
for (const menu of menus) {
if (menu.path && menu.component) {
return menu.path.startsWith("/") ? menu.path.slice(1) : menu.path
}
if (menu.children && menu.children.length > 0) {
const childPath = findFirstMenuPath(menu.children)
if (childPath) return childPath
}
}
return null
}
const firstMenuPath = findFirstMenuPath(authStore.menus)
if (firstMenuPath) {
const userTenantCode = authStore.user
? (authStore.user.tenantCode as string | undefined)
: undefined
const tenantCode =
tenantCodeFromUrl ||
extractTenantCodeFromPath(to.path) ||
userTenantCode
if (tenantCode) {
next({ path: `/${tenantCode}/${firstMenuPath}`, replace: true })
return
}
}
// 如果没有任何菜单跳转到404页面
const tenantCodeFor404 =
tenantCodeFromUrl ||
extractTenantCodeFromPath(to.path) ||
(authStore.user
? (authStore.user.tenantCode as string | undefined)
: undefined)
if (tenantCodeFor404) {
next({ path: `/${tenantCodeFor404}/404`, replace: true })
return
}
}
}
// 检查是否需要认证
if (to.meta.requiresAuth !== false) {
// 如果没有 token跳转到登录页
if (!authStore.token) {
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
if (tenantCode) {
next({
path: `/${tenantCode}/login`,
query: { redirect: to.fullPath },
})
} else {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
}
return
}
// 如果有 token 但没有用户信息,跳转到登录页
if (!authStore.user) {
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
if (tenantCode) {
next({
path: `/${tenantCode}/login`,
query: { redirect: to.fullPath },
})
} else {
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
}
return
}
}
// 如果已登录,访问登录页则重定向到首页
if (
(to.name === "Login" || to.name === "LoginFallback") &&
authStore.isAuthenticated
) {
// 确保动态路由已添加并等待生效
if (!dynamicRoutesAdded && authStore.menus.length > 0) {
await addDynamicRoutes()
}
// 重定向到带租户编码的根路径(路由守卫会处理跳转到第一个菜单)
const userTenantCode = authStore.user?.tenantCode || "default"
next({ path: `/${userTenantCode}` })
return
}
// 处理登录页面的租户编码
if (to.name === "LoginFallback" && !tenantCodeFromUrl) {
// 如果访问的是 /login但没有租户编码检查是否有用户信息中的租户编码
if (authStore.isAuthenticated && authStore.user?.tenantCode) {
const userTenantCode = authStore.user.tenantCode
next({ path: `/${userTenantCode}/login`, replace: true })
return
}
// 如果没有租户编码,允许访问(会显示租户输入框)
next()
return
}
// 检查角色权限
const requiredRoles = to.meta.roles
if (requiredRoles && requiredRoles.length > 0) {
if (!authStore.hasAnyRole(requiredRoles)) {
// 没有所需角色,跳转到 403 页面
next({ name: "Forbidden" })
return
}
}
// 检查权限
const requiredPermissions = to.meta.permissions
if (requiredPermissions && requiredPermissions.length > 0) {
if (!authStore.hasAnyPermission(requiredPermissions)) {
// 没有所需权限,跳转到 403 页面
next({ name: "Forbidden" })
return
}
}
next()
})
export default router export default router

View File

@ -5,7 +5,7 @@
* Pinia setup * Pinia setup
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, reactive } from 'vue' import { ref } from 'vue'
export const useAicreateStore = defineStore('aicreate', () => { export const useAicreateStore = defineStore('aicreate', () => {
// ─── 认证信息 ─── // ─── 认证信息 ───
@ -72,7 +72,13 @@ export const useAicreateStore = defineStore('aicreate', () => {
workId.value = '' workId.value = ''
workDetail.value = null workDetail.value = null
lastCreateRoute.value = '' lastCreateRoute.value = ''
// 清除所有 localStorage 中的创作相关数据
localStorage.removeItem('le_workId') localStorage.removeItem('le_workId')
localStorage.removeItem('le_phone')
localStorage.removeItem('le_orgId')
localStorage.removeItem('le_appSecret')
// 清除 sessionStorage 中的恢复数据
sessionStorage.removeItem('le_recovery')
} }
function saveRecoveryState() { function saveRecoveryState() {

View File

@ -49,8 +49,9 @@ service.interceptors.response.use(
const res = response.data const res = response.data
// 如果响应已经是统一格式 { code, message, data } // 如果响应已经是统一格式 { code, message, data }
// 统一使用 code === 200 表示成功,不再兼容 code === 0
if (res && typeof res === "object" && "code" in res) { if (res && typeof res === "object" && "code" in res) {
if (res.code !== 200 && res.code !== 0) { if (res.code !== 200) {
message.error(res.message || "请求失败") message.error(res.message || "请求失败")
if (res.code === 401) { if (res.code === 401) {
@ -76,17 +77,33 @@ service.interceptors.response.use(
return res return res
}, },
(error) => { (error) => {
const errorMessage = // 统一错误消息提取逻辑
error.response?.data?.message || error.message || "网络错误" const serverMessage = error.response?.data?.message
let errorMessage = "网络错误"
// 403 权限错误显示更友好的提示 if (serverMessage) {
if (error.response?.status === 403) { errorMessage = serverMessage
message.error(errorMessage || "您没有权限执行此操作") } else if (error.code === "ECONNABORTED" || error.code === "ERR_CANCELED") {
} else { errorMessage = "请求超时,请稍后重试"
message.error(errorMessage) } else if (!error.response) {
errorMessage = "网络连接失败,请检查网络"
} }
if (error.response?.status === 401) { // 特殊状态码友好提示
const status = error.response?.status
if (status === 403) {
errorMessage = "您没有权限执行此操作"
} else if (status === 404) {
errorMessage = "请求的资源不存在"
} else if (status === 429) {
errorMessage = "请求过于频繁,请稍后再试"
} else if (status && status >= 500) {
errorMessage = serverMessage || "服务器错误,请稍后重试"
}
message.error(errorMessage)
if (status === 401) {
removeToken() removeToken()
// 从 URL 获取租户编码,跳转到对应的登录页 // 从 URL 获取租户编码,跳转到对应的登录页
const path = window.location.pathname const path = window.location.pathname

View File

@ -0,0 +1,40 @@
/**
* HTML
* 使 DOMPurify XSS
*/
import DOMPurify from 'dompurify'
// 配置 DOMPurify允许安全的标签过滤危险内容
const config = {
ALLOWED_TAGS: [
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'p', 'br', 'hr', 'blockquote', 'pre', 'code',
'ul', 'ol', 'li',
'a', 'strong', 'em', 'b', 'i', 'u', 's', 'sub', 'sup',
'img', 'figure', 'figcaption',
'table', 'thead', 'tbody', 'tr', 'th', 'td',
'div', 'span', 'section',
'video', 'audio', 'source',
],
ALLOWED_ATTR: [
'href', 'target', 'rel',
'src', 'alt', 'title', 'width', 'height',
'class', 'style',
'controls', 'autoplay', 'loop', 'muted',
'colspan', 'rowspan',
],
// 强制给 a 标签添加 rel="noopener noreferrer"
ADD_ATTR: ['target'],
// 返回字符串而非 TrustedHTML
RETURN_TRUSTED_TYPE: false,
}
/**
* HTML
* @param dirty HTML
* @returns HTML
*/
export function sanitizeHtml(dirty: string): string {
if (!dirty) return ''
return DOMPurify.sanitize(dirty, config) as string
}

View File

@ -205,7 +205,7 @@
<div v-else> <div v-else>
<div v-for="notice in activity.notices" :key="notice.id" class="notice-item"> <div v-for="notice in activity.notices" :key="notice.id" class="notice-item">
<h4>{{ notice.title }}</h4> <h4>{{ notice.title }}</h4>
<div class="notice-content" v-html="notice.content"></div> <div class="notice-content" v-html="sanitizeNoticeContent(notice.content)"></div>
<span class="notice-time">{{ formatNoticeTime(notice) }}</span> <span class="notice-time">{{ formatNoticeTime(notice) }}</span>
</div> </div>
</div> </div>
@ -270,7 +270,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { message } from 'ant-design-vue' import { message } from 'ant-design-vue'
import { import {
@ -288,6 +288,7 @@ import {
type UserWork, type UserWork,
} from '@/api/public' } from '@/api/public'
import WorkSelector from './components/WorkSelector.vue' import WorkSelector from './components/WorkSelector.vue'
import { sanitizeHtml } from '@/utils/sanitize'
import dayjs from 'dayjs' import dayjs from 'dayjs'
const route = useRoute() const route = useRoute()
@ -355,11 +356,14 @@ watch(activeTab, (k) => {
const formatNoticeTime = (n: PublicActivityNotice) => const formatNoticeTime = (n: PublicActivityNotice) =>
formatDate(n.publishTime || n.createTime || '') formatDate(n.publishTime || n.createTime || '')
/** 活动详情富文本:后端字段为 content */ /** 安全过滤公告内容 */
const sanitizeNoticeContent = (content: string) => sanitizeHtml(content)
/** 活动详情富文本:后端字段为 content使用 DOMPurify 过滤 XSS */
const activityContentHtml = computed(() => { const activityContentHtml = computed(() => {
const a = activity.value const a = activity.value
if (!a) return '' if (!a) return ''
return a.content || a.description || '' return sanitizeHtml(a.content || a.description || '')
}) })
/** 子女账号:直接报名,不选参与者、不添加子女 */ /** 子女账号:直接报名,不选参与者、不添加子女 */
@ -506,10 +510,32 @@ const handleRegister = async () => {
} }
} }
// AbortController
let abortController: AbortController | null = null
onMounted(async () => { onMounted(async () => {
// AbortController
abortController = new AbortController()
const signal = abortController.signal
try {
await fetchDetail() await fetchDetail()
//
if (signal.aborted) return
await fetchChildren() await fetchChildren()
if (signal.aborted) return
await checkRegistrationStatus() await checkRegistrationStatus()
} catch {
//
}
})
onBeforeUnmount(() => {
//
if (abortController) {
abortController.abort()
abortController = null
}
}) })
</script> </script>

View File

@ -154,6 +154,7 @@ const fetchProfile = async () => {
editForm.city = user.value?.city || "" editForm.city = user.value?.city || ""
editForm.gender = user.value?.gender || "" editForm.gender = user.value?.gender || ""
} catch { } catch {
message.error("获取个人信息失败,请重新登录")
handleLogout() handleLogout()
} }
} }
@ -162,7 +163,9 @@ const fetchCounts = async () => {
try { try {
const regs = await publicMineApi.registrations({ page: 1, pageSize: 1 }) const regs = await publicMineApi.registrations({ page: 1, pageSize: 1 })
regCount.value = regs?.total || 0 regCount.value = regs?.total || 0
} catch { /* ignore */ } } catch {
//
}
} }
const handleSaveProfile = async () => { const handleSaveProfile = async () => {

View File

@ -1,6 +1,45 @@
{ {
"status": "failed", "status": "failed",
"failedTests": [ "failedTests": [
"e4788778f47ce754c470-78366da2488a38e4bf74" "9d2c64ecffd3e110d731-711c28f133678650e780",
"9d2c64ecffd3e110d731-fe346de0731628f4671c",
"9d2c64ecffd3e110d731-db33dcd76d5a1e99768c",
"9d2c64ecffd3e110d731-9297dc87e71cf8049c18",
"9d2c64ecffd3e110d731-3b96ce1bb43c29891ed8",
"5278bb25974bb5b30467-7267f918b0e23d0018e9",
"5278bb25974bb5b30467-01d7f27ad5dabb38bc16",
"5278bb25974bb5b30467-d985c012f382b77ee3e2",
"5278bb25974bb5b30467-bc5538522c8c9104281b",
"5278bb25974bb5b30467-6d4bba987db1a8a8e664",
"5e497f1b476ae4801891-096f396f52eae07e47cd",
"5e497f1b476ae4801891-a0be7e5e84c2110ec6b3",
"5e497f1b476ae4801891-72bfd371d0c73ac5433f",
"5e497f1b476ae4801891-3b8c1e3dc72f4b60f32a",
"548b3410af1c1c4329a4-d6b67fa005e2df476d89",
"548b3410af1c1c4329a4-ea2751f20cd472fb677c",
"548b3410af1c1c4329a4-b10e80103688849870f8",
"548b3410af1c1c4329a4-9099c299040a2722517d",
"548b3410af1c1c4329a4-ddbfcafb60d08e8d3a41",
"548b3410af1c1c4329a4-2874b8848c1048225488",
"8e44beb98beac02379e2-b12444ec2975738a5174",
"8e44beb98beac02379e2-97d5dbbe38e836eeef65",
"8e44beb98beac02379e2-c26833ac3cee7ec635ec",
"8e44beb98beac02379e2-3f62d23b0088a59609a2",
"d09d6a21d9d9919a934e-e40717b31e5d34dfe728",
"d09d6a21d9d9919a934e-cd948d12e3ce5873a07a",
"d09d6a21d9d9919a934e-3f71e7436fb82e84eec2",
"d09d6a21d9d9919a934e-e6100adedf467cb88bed",
"0cfd5280d7a36c395b2c-01864c0a44f899d1349e",
"0cfd5280d7a36c395b2c-bea28f58ad2bc25f3d3b",
"0cfd5280d7a36c395b2c-e6d1408aa9bb6750436c",
"a1b3712b214f85e87482-6f12cd621402ef88812b",
"a1b3712b214f85e87482-dc0881f1ba480b165eef",
"a1b3712b214f85e87482-8fc120a159669c7b55b0",
"a1b3712b214f85e87482-54d8089c589ef3f70da7",
"a1b3712b214f85e87482-4ede2195466e048f67a5",
"1a8e227d47c25e362391-d04ad6cee9a1a415827a",
"1a8e227d47c25e362391-fe2980c67f8667e3e086",
"1a8e227d47c25e362391-ca8afae6f5c9396215de",
"1a8e227d47c25e362391-c436493cfe36566cc588"
] ]
} }

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-01 创建活动页表单渲染
- Location: e2e\admin\contest-create.spec.ts:19:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-02 必填字段校验
- Location: e2e\admin\contest-create.spec.ts:35:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-03 填写活动信息
- Location: e2e\admin\contest-create.spec.ts:55:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-04 时间范围选择器可见
- Location: e2e\admin\contest-create.spec.ts:71:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

View File

@ -0,0 +1,185 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: admin\contest-create.spec.ts >> 创建活动 >> CC-05 返回按钮功能
- Location: e2e\admin\contest-create.spec.ts:85:3
# Error details
```
TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
Call log:
- waiting for locator('.layout, .login-container') to be visible
```
# Test source
```ts
513 | status: 200,
514 | contentType: 'application/json',
515 | body: JSON.stringify({ code: 200, message: 'success', data: MOCK_USERS, timestamp: Date.now(), path: '/api/users' }),
516 | })
517 | }
518 | })
519 |
520 | // 租户信息
521 | await page.route('**/api/tenants/my-tenant', async (route) => {
522 | await route.fulfill({
523 | status: 200,
524 | contentType: 'application/json',
525 | body: JSON.stringify({
526 | code: 200,
527 | message: 'success',
528 | data: { id: 2, name: '广东省立中山图书馆', code: TENANT_CODE, tenantType: 'library' },
529 | timestamp: Date.now(),
530 | path: '/api/tenants/my-tenant',
531 | }),
532 | })
533 | })
534 |
535 | // 评审任务列表(评委端)
536 | await page.route('**/api/activities/review**', async (route) => {
537 | await route.fulfill({
538 | status: 200,
539 | contentType: 'application/json',
540 | body: JSON.stringify({
541 | code: 200,
542 | message: 'success',
543 | data: {
544 | list: [
545 | { id: 1, contestName: '少儿绘本创作大赛', totalWorks: 10, reviewedWorks: 5, status: 'in_progress' },
546 | ],
547 | total: 1,
548 | },
549 | timestamp: Date.now(),
550 | }),
551 | })
552 | })
553 |
554 | // 评审规则下拉选项(创建活动页使用)
555 | await page.route('**/api/contests/review-rules/select**', async (route) => {
556 | await route.fulfill({
557 | status: 200,
558 | contentType: 'application/json',
559 | body: JSON.stringify({
560 | code: 200,
561 | message: 'success',
562 | data: [
563 | { id: 1, ruleName: '标准评审规则' },
564 | ],
565 | timestamp: Date.now(),
566 | path: '/api/contests/review-rules/select',
567 | }),
568 | })
569 | })
570 |
571 | // 兜底拦截:防止未 mock 的请求到达真实后端(返回空数据而非 401
572 | await page.route('**/api/**', async (route) => {
573 | const url = route.request().url()
574 | const method = route.request().method()
575 | // 只拦截未被更具体 mock 处理的请求
576 | await route.fulfill({
577 | status: 200,
578 | contentType: 'application/json',
579 | body: JSON.stringify({
580 | code: 200,
581 | message: 'success',
582 | data: method === 'GET' ? { list: [], total: 0, page: 1, pageSize: 10 } : { id: 0 },
583 | timestamp: Date.now(),
584 | path: new URL(url).pathname,
585 | }),
586 | })
587 | })
588 | }
589 |
590 | /**
591 | * 注入登录态到浏览器
592 | * 通过设置 Cookie 模拟已登录状态
593 | */
594 | export async function injectAuthState(page: Page): Promise<void> {
595 | // 先访问页面以便能设置 Cookie
596 | await page.goto('/p/login')
597 |
598 | // 注入 Cookie与 setToken 函数一致path 为 '/'
599 | await page.evaluate((token) => {
600 | document.cookie = `token=${encodeURIComponent(token)}; path=/; max-age=${7 * 24 * 60 * 60}`
601 | }, MOCK_TOKEN)
602 | }
603 |
604 | /**
605 | * 导航到管理端页面(已注入登录态后)
606 | * 等待路由守卫完成和页面渲染
607 | */
608 | export async function navigateToAdmin(page: Page, path: string = ''): Promise<void> {
609 | const targetUrl = `/${TENANT_CODE}${path}`
610 | await page.goto(targetUrl)
611 |
612 | // 等待页面基本加载完成BasicLayout 渲染)
> 613 | await page.waitForSelector('.layout, .login-container', { timeout: 15_000 })
| ^ TimeoutError: page.waitForSelector: Timeout 15000ms exceeded.
614 | }
615 |
616 | /**
617 | * 等待 Ant Design 表格加载完成
618 | */
619 | export async function waitForTable(page: Page): Promise<void> {
620 | await page.waitForSelector('.ant-table', { timeout: 10_000 })
621 | // 等待表格数据加载
622 | await page.waitForSelector('.ant-table-tbody tr', { timeout: 10_000 })
623 | }
624 |
625 | // ==================== 组件预热 ====================
626 |
627 | /** 标记是否已完成组件预热Vite 编译缓存只需触发一次) */
628 | let componentsWarmedUp = false
629 |
630 | /**
631 | * 预热管理端页面组件
632 | * 通过导航到活动列表页触发 Vite 编译,避免首个测试因组件加载慢而失败
633 | */
634 | async function warmupComponents(page: Page): Promise<void> {
635 | if (componentsWarmedUp) return
636 | try {
637 | // 展开活动管理子菜单
638 | const submenu = page.locator('.ant-menu-submenu').filter({ hasText: '活动管理' }).first()
639 | await submenu.click()
640 | await page.waitForTimeout(500)
641 | // 点击活动列表触发组件加载
642 | await submenu.locator('.ant-menu-item').filter({ hasText: '活动列表' }).first().click()
643 | await page.waitForSelector('.contests-page', { timeout: 15_000 })
644 | // 导航回工作台
645 | await page.locator('.ant-menu-item').filter({ hasText: '工作台' }).first().click()
646 | await page.waitForTimeout(500)
647 | componentsWarmedUp = true
648 | } catch {
649 | // 预热失败不影响测试(可能组件已被缓存)
650 | }
651 | }
652 |
653 | // ==================== 扩展 Fixture ====================
654 |
655 | export const test = base.extend<AdminFixtures>({
656 | adminPage: async ({ page }, use) => {
657 | // 设置 API Mock
658 | await setupApiMocks(page)
659 | // 注入登录态
660 | await injectAuthState(page)
661 | // 导航到管理端首页
662 | await navigateToAdmin(page)
663 | // 等待侧边栏加载
664 | await page.waitForSelector('.custom-sider', { timeout: 15_000 })
665 | // 预热组件(首次运行时触发 Vite 编译)
666 | await warmupComponents(page)
667 | await use(page)
668 | },
669 | })
670 |
671 | export { expect }
672 |
```

Some files were not shown because too many files have changed in this diff Show More