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:
parent
f7f97c64e4
commit
f03991819d
@ -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";
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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/**");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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:";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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 是否有效
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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-彻底失败';
|
||||||
@ -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);
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
-- V12: 修复 tenant_id 类型一致性 — 将 int 改为 BIGINT 与 Java 实体 Long 对齐
|
||||||
|
-- 日期: 2026-04-09
|
||||||
|
-- 背景: V6 中部分表 tenant_id 使用 int,Java 实体使用 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';
|
||||||
101
frontend/e2e/admin/contest-create.spec.ts
Normal file
101
frontend/e2e/admin/contest-create.spec.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
90
frontend/e2e/admin/contests.spec.ts
Normal file
90
frontend/e2e/admin/contests.spec.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
87
frontend/e2e/admin/dashboard.spec.ts
Normal file
87
frontend/e2e/admin/dashboard.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
117
frontend/e2e/admin/login.spec.ts
Normal file
117
frontend/e2e/admin/login.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
78
frontend/e2e/admin/navigation.spec.ts
Normal file
78
frontend/e2e/admin/navigation.spec.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
79
frontend/e2e/admin/registrations.spec.ts
Normal file
79
frontend/e2e/admin/registrations.spec.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
61
frontend/e2e/admin/reviews.spec.ts
Normal file
61
frontend/e2e/admin/reviews.spec.ts
Normal 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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
111
frontend/e2e/admin/users.spec.ts
Normal file
111
frontend/e2e/admin/users.spec.ts
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
77
frontend/e2e/admin/works.spec.ts
Normal file
77
frontend/e2e/admin/works.spec.ts
Normal 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
328
frontend/e2e/audit/audit-fixes.spec.ts
Normal file
328
frontend/e2e/audit/audit-fixes.spec.ts
Normal 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 }) => {
|
||||||
|
// 设置一个已过期的 Token(exp 为 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
671
frontend/e2e/fixtures/admin.fixture.ts
Normal file
671
frontend/e2e/fixtures/admin.fixture.ts
Normal 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 }
|
||||||
26
frontend/package-lock.json
generated
26
frontend/package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
@ -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
@ -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) => {
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
40
frontend/src/utils/sanitize.ts
Normal file
40
frontend/src/utils/sanitize.ts
Normal 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
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -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 |
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@ -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 |
@ -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 |
@ -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 |
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@ -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
Loading…
Reference in New Issue
Block a user