fix: 清理 h5Url 死代码并修复后端代理 Content-Type 导致前端解析失败
- 移除 LeaiTokenVO.h5Url 字段、LeaiConfig.h5Url 配置及 yml 中的 h5-url - 删除 LeaiAuthController.authRedirect() 方法和 LeaiAuthRedirectDTO - 移除前端 authRedirectUrl 状态及 WelcomeView 企业认证按钮死代码 - 修复 LeaiProxyController 返回 text/plain 导致前端无法解析 JSON 的问题 (改用 ResponseEntity<String> + application/json Content-Type) - 修复前端 aicreate 所有视图组件中 res.data 双重取值问题 (publicApi 拦截器已自动解包,无需再取 .data) - 同步更新 E2E 测试 mock 数据移除 h5Url Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@ -27,6 +27,7 @@
|
||||
<hutool.version>5.8.32</hutool.version>
|
||||
<fastjson2.version>2.0.53</fastjson2.version>
|
||||
<aliyun-oss.version>3.17.1</aliyun-oss.version>
|
||||
<aliyun-dysmsapi.version>2.0.24</aliyun-dysmsapi.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
@ -137,6 +138,13 @@
|
||||
<version>${aliyun-oss.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- 阿里云短信 SDK -->
|
||||
<dependency>
|
||||
<groupId>com.aliyun</groupId>
|
||||
<artifactId>dysmsapi20170525</artifactId>
|
||||
<version>${aliyun-dysmsapi.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
|
||||
@ -18,4 +18,28 @@ public final class CacheConstants {
|
||||
|
||||
/** Token 黑名单 key 前缀(用于登出/密码修改后使旧 Token 失效) */
|
||||
public static final String TOKEN_BLACKLIST_PREFIX = "token:blacklist:";
|
||||
|
||||
/** 短信验证码 key 前缀(值: 6位数字验证码,TTL: 5分钟) */
|
||||
public static final String SMS_CODE_PREFIX = "sms:code:";
|
||||
|
||||
/** 短信发送间隔 key 前缀(值: "1",TTL: 60秒) */
|
||||
public static final String SMS_INTERVAL_PREFIX = "sms:interval:";
|
||||
|
||||
/** 短信每日发送次数 key 前缀(值: 当日发送次数,TTL: 到当天24:00) */
|
||||
public static final String SMS_DAILY_PREFIX = "sms:daily:";
|
||||
|
||||
/** 短信验证失败次数 key 前缀(值: 失败次数,TTL: 5分钟) */
|
||||
public static final String SMS_VERIFY_PREFIX = "sms:verify:";
|
||||
|
||||
/** 短信验证码有效期(分钟) */
|
||||
public static final int SMS_CODE_EXPIRE_MINUTES = 5;
|
||||
|
||||
/** 短信发送间隔(秒) */
|
||||
public static final int SMS_INTERVAL_SECONDS = 60;
|
||||
|
||||
/** 短信每日发送上限 */
|
||||
public static final int SMS_DAILY_LIMIT = 15;
|
||||
|
||||
/** 短信验证失败上限(超过后需重新获取验证码) */
|
||||
public static final int SMS_VERIFY_FAIL_LIMIT = 5;
|
||||
}
|
||||
|
||||
@ -28,6 +28,13 @@ public enum ErrorCode {
|
||||
USER_DUPLICATE(1004, "用户名已存在"),
|
||||
USER_PHONE_DUPLICATE(1005, "手机号已注册"),
|
||||
|
||||
// ====== 短信验证码 10xx(接续用户模块) ======
|
||||
SMS_SEND_TOO_FREQUENT(1006, "发送验证码太频繁,请稍后再试"),
|
||||
SMS_DAILY_LIMIT_EXCEEDED(1007, "今日验证码发送次数已达上限"),
|
||||
SMS_CODE_EXPIRED(1008, "验证码已过期,请重新获取"),
|
||||
SMS_CODE_INVALID(1009, "验证码错误"),
|
||||
SMS_SEND_FAILED(1010, "验证码发送失败,请稍后再试"),
|
||||
|
||||
// ====== 活动模块 20xx ======
|
||||
CONTEST_NOT_FOUND(2001, "活动不存在"),
|
||||
CONTEST_ALREADY_PUBLISHED(2002, "活动已发布"),
|
||||
|
||||
@ -4,6 +4,8 @@ import com.competition.common.enums.ErrorCode;
|
||||
import com.competition.common.result.Result;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.security.access.AccessDeniedException;
|
||||
import org.springframework.security.core.AuthenticationException;
|
||||
@ -73,6 +75,24 @@ public class GlobalExceptionHandler {
|
||||
return Result.error(404, ErrorCode.NOT_FOUND.getMessage(), request.getRequestURI());
|
||||
}
|
||||
|
||||
/** 数据完整性违反(唯一约束冲突) */
|
||||
@ExceptionHandler(DataIntegrityViolationException.class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
public Result<Void> handleDataIntegrityViolation(DataIntegrityViolationException e, HttpServletRequest request) {
|
||||
String message = parseDuplicateMessage(e);
|
||||
log.warn("数据冲突,路径:{},消息:{}", request.getRequestURI(), message);
|
||||
return Result.error(409, message, request.getRequestURI());
|
||||
}
|
||||
|
||||
/** 主键/唯一键重复(MySQL 驱动可能直接抛此异常) */
|
||||
@ExceptionHandler(DuplicateKeyException.class)
|
||||
@ResponseStatus(HttpStatus.CONFLICT)
|
||||
public Result<Void> handleDuplicateKey(DuplicateKeyException e, HttpServletRequest request) {
|
||||
String message = parseDuplicateMessage(e);
|
||||
log.warn("唯一键冲突,路径:{},消息:{}", request.getRequestURI(), message);
|
||||
return Result.error(409, message, request.getRequestURI());
|
||||
}
|
||||
|
||||
/** 兜底:未知异常 */
|
||||
@ExceptionHandler(Exception.class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
@ -80,4 +100,33 @@ public class GlobalExceptionHandler {
|
||||
log.error("系统异常,路径:{},消息:{}", request.getRequestURI(), e.getMessage(), e);
|
||||
return Result.error(500, ErrorCode.INTERNAL_ERROR.getMessage(), request.getRequestURI());
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析数据库唯一约束冲突的具体字段,返回友好提示
|
||||
*/
|
||||
private String parseDuplicateMessage(Exception e) {
|
||||
String rootMsg = getRootCauseMessage(e);
|
||||
if (rootMsg == null) {
|
||||
return ErrorCode.CONFLICT.getMessage();
|
||||
}
|
||||
String lowerMsg = rootMsg.toLowerCase();
|
||||
if (lowerMsg.contains("phone")) {
|
||||
return ErrorCode.USER_PHONE_DUPLICATE.getMessage();
|
||||
}
|
||||
if (lowerMsg.contains("username")) {
|
||||
return ErrorCode.USER_DUPLICATE.getMessage();
|
||||
}
|
||||
return ErrorCode.CONFLICT.getMessage();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取异常链中的根因消息
|
||||
*/
|
||||
private String getRootCauseMessage(Throwable e) {
|
||||
Throwable cause = e;
|
||||
while (cause.getCause() != null) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return cause.getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,4 @@ public class LeaiConfig {
|
||||
|
||||
/** 乐读派后端 API 地址 */
|
||||
private String apiUrl = "http://192.168.1.120:8080";
|
||||
|
||||
/** 乐读派 H5 前端地址 */
|
||||
private String h5Url = "http://192.168.1.120:3001";
|
||||
}
|
||||
|
||||
@ -4,22 +4,16 @@ import com.competition.common.exception.BusinessException;
|
||||
import com.competition.common.result.Result;
|
||||
import com.competition.common.util.SecurityUtil;
|
||||
import com.competition.modules.leai.config.LeaiConfig;
|
||||
import com.competition.modules.leai.dto.LeaiAuthRedirectDTO;
|
||||
import com.competition.modules.leai.service.LeaiApiClient;
|
||||
import com.competition.modules.leai.vo.LeaiTokenVO;
|
||||
import com.competition.modules.sys.entity.SysUser;
|
||||
import com.competition.modules.sys.service.ISysUserService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 乐读派认证入口控制器
|
||||
* 前端 iframe 模式的主入口
|
||||
@ -61,8 +55,6 @@ public class LeaiAuthController {
|
||||
LeaiTokenVO vo = new LeaiTokenVO();
|
||||
vo.setToken(token);
|
||||
vo.setOrgId(leaiConfig.getOrgId()); // 即租户 tenant_code
|
||||
vo.setH5Url(leaiConfig.getH5Url());
|
||||
vo.setPhone(phone);
|
||||
|
||||
log.info("[乐读派] 获取创作Token成功, userId={}, phone={}", userId, phone);
|
||||
return Result.success(vo);
|
||||
@ -75,42 +67,6 @@ public class LeaiAuthController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转模式备选:换 token + 302 重定向到 H5
|
||||
* 需要登录认证
|
||||
*/
|
||||
@GetMapping
|
||||
@Operation(summary = "重定向到乐读派 H5 创作页")
|
||||
public void authRedirect(LeaiAuthRedirectDTO dto, HttpServletResponse response) throws IOException {
|
||||
Long userId = SecurityUtil.getCurrentUserId();
|
||||
SysUser user = sysUserService.getById(userId);
|
||||
if (user == null || user.getPhone() == null || user.getPhone().isEmpty()) {
|
||||
response.sendError(401, "请先登录并绑定手机号");
|
||||
return;
|
||||
}
|
||||
|
||||
String phone = user.getPhone();
|
||||
try {
|
||||
String token = leaiApiClient.exchangeToken(phone);
|
||||
|
||||
StringBuilder url = new StringBuilder(leaiConfig.getH5Url())
|
||||
.append("/?token=").append(URLEncoder.encode(token, StandardCharsets.UTF_8))
|
||||
.append("&orgId=").append(URLEncoder.encode(leaiConfig.getOrgId(), StandardCharsets.UTF_8))
|
||||
.append("&phone=").append(URLEncoder.encode(phone, StandardCharsets.UTF_8));
|
||||
|
||||
if (dto.getReturnPath() != null && !dto.getReturnPath().isEmpty()) {
|
||||
url.append("&returnPath=").append(URLEncoder.encode(dto.getReturnPath(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
log.info("[乐读派] 重定向到H5, userId={}, phone={}", userId, phone);
|
||||
response.sendRedirect(url.toString());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[乐读派] 重定向失败, userId={}", userId, e);
|
||||
response.sendError(500, "跳转乐读派失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* iframe 内 Token 刷新接口
|
||||
* 前端 JS 在收到 TOKEN_EXPIRED postMessage 时调用此接口
|
||||
@ -132,7 +88,6 @@ public class LeaiAuthController {
|
||||
LeaiTokenVO vo = new LeaiTokenVO();
|
||||
vo.setToken(token);
|
||||
vo.setOrgId(leaiConfig.getOrgId());
|
||||
vo.setPhone(phone);
|
||||
|
||||
log.info("[乐读派] Token刷新成功, userId={}", userId);
|
||||
return Result.success(vo);
|
||||
|
||||
@ -0,0 +1,197 @@
|
||||
package com.competition.modules.leai.controller;
|
||||
|
||||
import com.competition.common.exception.BusinessException;
|
||||
import com.competition.common.util.SecurityUtil;
|
||||
import com.competition.modules.leai.config.LeaiConfig;
|
||||
import com.competition.modules.leai.service.LeaiApiClient;
|
||||
import com.competition.modules.sys.entity.SysUser;
|
||||
import com.competition.modules.sys.service.ISysUserService;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 乐读派代理控制器
|
||||
* 前端所有乐读派请求经由本控制器代理转发,前端不持有 phone/orgId/appSecret
|
||||
* 后端自动注入 orgId + phone,使用 HMAC 签名与乐读派通信
|
||||
*/
|
||||
@Slf4j
|
||||
@Tag(name = "乐读派代理")
|
||||
@RestController
|
||||
@RequestMapping("/leai-proxy")
|
||||
@RequiredArgsConstructor
|
||||
public class LeaiProxyController {
|
||||
|
||||
private final LeaiApiClient leaiApiClient;
|
||||
private final LeaiConfig leaiConfig;
|
||||
private final ISysUserService sysUserService;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
/**
|
||||
* 获取当前用户手机号,校验非空
|
||||
*/
|
||||
private String getPhoneOrThrow() {
|
||||
Long userId = SecurityUtil.getCurrentUserId();
|
||||
SysUser user = sysUserService.getById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(404, "用户不存在");
|
||||
}
|
||||
String phone = user.getPhone();
|
||||
if (phone == null || phone.isEmpty()) {
|
||||
throw new BusinessException(400, "用户未绑定手机号,无法使用创作功能");
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建带 orgId + phone 的基础 body
|
||||
*/
|
||||
private Map<String, Object> buildBaseBody() {
|
||||
Map<String, Object> body = new HashMap<>();
|
||||
body.put("orgId", leaiConfig.getOrgId());
|
||||
body.put("phone", getPhoneOrThrow());
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将乐读派原始 JSON 响应以 application/json 返回给前端
|
||||
* (String 返回类型默认 Content-Type 为 text/plain,需手动设为 JSON)
|
||||
*/
|
||||
private ResponseEntity<String> jsonOk(String body) {
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 代理接口
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* 图片上传
|
||||
* POST /leai-proxy/upload → 乐读派 /api/v1/creation/upload
|
||||
*/
|
||||
@PostMapping("/upload")
|
||||
@Operation(summary = "图片上传代理")
|
||||
public ResponseEntity<String> proxyUpload(@RequestParam("file") MultipartFile file) {
|
||||
log.info("[乐读派代理] 图片上传, fileName={}, size={}", file.getOriginalFilename(), file.getSize());
|
||||
return jsonOk(leaiApiClient.proxyUpload("/creation/upload", file));
|
||||
}
|
||||
|
||||
/**
|
||||
* 角色提取
|
||||
* POST /leai-proxy/extract → 乐读派 /api/v1/creation/extract-original
|
||||
*/
|
||||
@PostMapping("/extract")
|
||||
@Operation(summary = "角色提取代理")
|
||||
public ResponseEntity<String> proxyExtract(@RequestBody Map<String, Object> requestBody) {
|
||||
Map<String, Object> body = buildBaseBody();
|
||||
body.putAll(requestBody);
|
||||
log.info("[乐读派代理] 角色提取, imageUrl={}", requestBody.get("imageUrl"));
|
||||
return jsonOk(leaiApiClient.proxyPost("/creation/extract-original", body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 故事创作
|
||||
* POST /leai-proxy/create-story → 乐读派 /api/v1/creation/image-story
|
||||
*/
|
||||
@PostMapping("/create-story")
|
||||
@Operation(summary = "故事创作代理")
|
||||
public ResponseEntity<String> proxyCreateStory(@RequestBody Map<String, Object> requestBody) {
|
||||
Map<String, Object> body = buildBaseBody();
|
||||
body.putAll(requestBody);
|
||||
// 默认关闭自动配音
|
||||
body.putIfAbsent("enableVoice", false);
|
||||
log.info("[乐读派代理] 故事创作, imageUrl={}", requestBody.get("imageUrl"));
|
||||
return jsonOk(leaiApiClient.proxyPost("/creation/image-story", body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询作品详情
|
||||
* GET /leai-proxy/work/{id} → 乐读派 /api/v1/query/work/{id}
|
||||
*/
|
||||
@GetMapping("/work/{id}")
|
||||
@Operation(summary = "查询作品详情代理")
|
||||
public ResponseEntity<String> proxyGetWork(@PathVariable String id) {
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("orgId", leaiConfig.getOrgId());
|
||||
params.put("phone", getPhoneOrThrow());
|
||||
log.info("[乐读派代理] 查询作品详情, workId={}", id);
|
||||
return jsonOk(leaiApiClient.proxyGet("/query/work/" + id, params));
|
||||
}
|
||||
|
||||
/**
|
||||
* 额度校验
|
||||
* POST /leai-proxy/validate → 乐读派 /api/v1/query/validate
|
||||
*/
|
||||
@PostMapping("/validate")
|
||||
@Operation(summary = "额度校验代理")
|
||||
public ResponseEntity<String> proxyValidate(@RequestBody(required = false) Map<String, Object> requestBody) {
|
||||
Map<String, Object> body = buildBaseBody();
|
||||
if (requestBody != null) {
|
||||
body.putAll(requestBody);
|
||||
}
|
||||
body.putIfAbsent("apiType", "A3");
|
||||
log.info("[乐读派代理] 额度校验");
|
||||
return jsonOk(leaiApiClient.proxyPost("/query/validate", body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑作品
|
||||
* PUT /leai-proxy/work/{id} → 乐读派 /api/v1/update/work/{id}
|
||||
*/
|
||||
@PutMapping("/work/{id}")
|
||||
@Operation(summary = "编辑作品代理")
|
||||
public ResponseEntity<String> proxyUpdateWork(@PathVariable String id, @RequestBody Map<String, Object> requestBody) {
|
||||
Map<String, Object> body = new HashMap<>(requestBody);
|
||||
log.info("[乐读派代理] 编辑作品, workId={}", id);
|
||||
return jsonOk(leaiApiClient.proxyPut("/update/work/" + id, body));
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新配音
|
||||
* POST /leai-proxy/batch-audio → 乐读派 /api/v1/update/batch-audio
|
||||
*/
|
||||
@PostMapping("/batch-audio")
|
||||
@Operation(summary = "批量更新配音代理")
|
||||
public ResponseEntity<String> proxyBatchAudio(@RequestBody Map<String, Object> requestBody) {
|
||||
Map<String, Object> body = buildBaseBody();
|
||||
body.putAll(requestBody);
|
||||
log.info("[乐读派代理] 批量更新配音, workId={}", requestBody.get("workId"));
|
||||
return jsonOk(leaiApiClient.proxyPost("/update/batch-audio", body));
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 配音
|
||||
* POST /leai-proxy/voice → 乐读派 /api/v1/creation/voice
|
||||
*/
|
||||
@PostMapping("/voice")
|
||||
@Operation(summary = "AI配音代理")
|
||||
public ResponseEntity<String> proxyVoice(@RequestBody Map<String, Object> requestBody) {
|
||||
Map<String, Object> body = buildBaseBody();
|
||||
body.putAll(requestBody);
|
||||
log.info("[乐读派代理] AI配音");
|
||||
return jsonOk(leaiApiClient.proxyPost("/creation/voice", body));
|
||||
}
|
||||
|
||||
/**
|
||||
* STS 临时凭证
|
||||
* POST /leai-proxy/sts-token → 乐读派 /api/v1/oss/sts-token
|
||||
*/
|
||||
@PostMapping("/sts-token")
|
||||
@Operation(summary = "STS临时凭证代理")
|
||||
public ResponseEntity<String> proxyStsToken() {
|
||||
Map<String, Object> body = buildBaseBody();
|
||||
log.info("[乐读派代理] 获取STS凭证");
|
||||
return jsonOk(leaiApiClient.proxyPost("/oss/sts-token", body));
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
package com.competition.modules.leai.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 乐读派重定向请求 DTO
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "乐读派重定向请求")
|
||||
public class LeaiAuthRedirectDTO {
|
||||
|
||||
@Schema(description = "重定向后的路径")
|
||||
private String returnPath;
|
||||
}
|
||||
@ -11,8 +11,11 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.time.Instant;
|
||||
@ -269,4 +272,147 @@ public class LeaiApiClient {
|
||||
return ZonedDateTime.ofInstant(Instant.now().minusSeconds(7200), ZoneOffset.UTC)
|
||||
.format(DateTimeFormatter.ISO_INSTANT);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 通用代理方法(后端代理前端请求,HMAC 签名)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
private static final int PROXY_TIMEOUT_MS = 120_000;
|
||||
|
||||
/**
|
||||
* 通用 GET 代理(HMAC 签名)
|
||||
* @param path 乐读派 API 路径,如 /query/work/123
|
||||
* @param params 查询参数
|
||||
* @return 乐读派原始 JSON 响应
|
||||
*/
|
||||
public String proxyGet(String path, Map<String, Object> params) {
|
||||
Map<String, Object> queryParams = new TreeMap<>(params);
|
||||
Map<String, String> hmacHeaders = buildHmacHeaders(queryParams);
|
||||
|
||||
String url = leaiConfig.getApiUrl() + "/api/v1" + path;
|
||||
log.debug("[乐读派代理] GET {}", url);
|
||||
|
||||
try {
|
||||
HttpResponse httpResponse = HttpRequest.get(url)
|
||||
.form(queryParams)
|
||||
.addHeaders(hmacHeaders)
|
||||
.timeout(PROXY_TIMEOUT_MS)
|
||||
.execute();
|
||||
|
||||
String responseBody = httpResponse.body();
|
||||
log.debug("[乐读派代理] GET {} 响应: {}", url, responseBody);
|
||||
return responseBody;
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("[乐读派代理] GET {} 失败", url, e);
|
||||
throw new BusinessException(502, "乐读派代理请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 POST 代理(HMAC 签名 + JSON body)
|
||||
* @param path 乐读派 API 路径,如 /creation/extract-original
|
||||
* @param body 请求体(会自动注入 orgId)
|
||||
* @return 乐读派原始 JSON 响应
|
||||
*/
|
||||
public String proxyPost(String path, Map<String, Object> body) {
|
||||
// POST body 不参与签名,签名仅基于 query params
|
||||
Map<String, Object> signParams = new TreeMap<>();
|
||||
Map<String, String> hmacHeaders = buildHmacHeaders(signParams);
|
||||
|
||||
String url = leaiConfig.getApiUrl() + "/api/v1" + path;
|
||||
log.debug("[乐读派代理] POST {} body={}", url, body);
|
||||
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(body);
|
||||
|
||||
HttpResponse httpResponse = HttpRequest.post(url)
|
||||
.body(jsonBody)
|
||||
.contentType("application/json")
|
||||
.addHeaders(hmacHeaders)
|
||||
.timeout(PROXY_TIMEOUT_MS)
|
||||
.execute();
|
||||
|
||||
String responseBody = httpResponse.body();
|
||||
log.debug("[乐读派代理] POST {} 响应: {}", url, responseBody);
|
||||
return responseBody;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[乐读派代理] POST {} 失败", url, e);
|
||||
throw new BusinessException(502, "乐读派代理请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用 PUT 代理(HMAC 签名 + JSON body)
|
||||
* @param path 乐读派 API 路径,如 /update/work/123
|
||||
* @param body 请求体
|
||||
* @return 乐读派原始 JSON 响应
|
||||
*/
|
||||
public String proxyPut(String path, Map<String, Object> body) {
|
||||
Map<String, Object> signParams = new TreeMap<>();
|
||||
Map<String, String> hmacHeaders = buildHmacHeaders(signParams);
|
||||
|
||||
String url = leaiConfig.getApiUrl() + "/api/v1" + path;
|
||||
log.debug("[乐读派代理] PUT {} body={}", url, body);
|
||||
|
||||
try {
|
||||
String jsonBody = objectMapper.writeValueAsString(body);
|
||||
|
||||
HttpResponse httpResponse = HttpRequest.put(url)
|
||||
.body(jsonBody)
|
||||
.contentType("application/json")
|
||||
.addHeaders(hmacHeaders)
|
||||
.timeout(PROXY_TIMEOUT_MS)
|
||||
.execute();
|
||||
|
||||
String responseBody = httpResponse.body();
|
||||
log.debug("[乐读派代理] PUT {} 响应: {}", url, responseBody);
|
||||
return responseBody;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[乐读派代理] PUT {} 失败", url, e);
|
||||
throw new BusinessException(502, "乐读派代理请求失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传代理(multipart → 乐读派)
|
||||
* @param path 乐读派 API 路径,如 /creation/upload
|
||||
* @param file 上传的文件
|
||||
* @return 乐读派原始 JSON 响应
|
||||
*/
|
||||
public String proxyUpload(String path, MultipartFile file) {
|
||||
Map<String, Object> signParams = new TreeMap<>();
|
||||
Map<String, String> hmacHeaders = buildHmacHeaders(signParams);
|
||||
|
||||
String url = leaiConfig.getApiUrl() + "/api/v1" + path;
|
||||
log.debug("[乐读派代理] UPLOAD {} fileName={}", url, file.getOriginalFilename());
|
||||
|
||||
try {
|
||||
byte[] fileBytes = file.getBytes();
|
||||
String originalFilename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "upload";
|
||||
|
||||
HttpResponse httpResponse = HttpRequest.post(url)
|
||||
.addHeaders(hmacHeaders)
|
||||
.form("file", fileBytes, originalFilename)
|
||||
.contentType("multipart/form-data")
|
||||
.timeout(PROXY_TIMEOUT_MS)
|
||||
.execute();
|
||||
|
||||
String responseBody = httpResponse.body();
|
||||
log.debug("[乐读派代理] UPLOAD {} 响应: {}", url, responseBody);
|
||||
return responseBody;
|
||||
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[乐读派代理] UPLOAD {} 失败", url, e);
|
||||
throw new BusinessException(502, "乐读派代理上传失败: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -88,9 +88,15 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 旧数据或重复推送,忽略
|
||||
// 旧数据或重复推送,忽略状态更新
|
||||
// 但如果 remoteStatus >= 3 且本地缺少页面数据,需要补充拉取
|
||||
if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED && !hasPages(localWork.getId())) {
|
||||
log.info("[{}] 状态未变但页面缺失,补充拉取: remoteWorkId={}, status={}", source, remoteWorkId, remoteStatus);
|
||||
ensurePagesSaved(localWork.getId(), remoteWorkId, remoteData.get("pageList"));
|
||||
} else {
|
||||
log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localStatus);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增作品记录
|
||||
@ -143,9 +149,9 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
|
||||
ugcWorkMapper.insert(work);
|
||||
|
||||
// 如果有 pageList 且 status >= 3,保存页面数据
|
||||
// 如果 status >= 3,确保页面数据已保存
|
||||
if (work.getStatus() != null && work.getStatus() >= LeaiApiClient.STATUS_COMPLETED) {
|
||||
savePageList(work.getId(), remoteData.get("pageList"));
|
||||
ensurePagesSaved(work.getId(), remoteWorkId, remoteData.get("pageList"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -246,9 +252,9 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
return;
|
||||
}
|
||||
|
||||
// status >= 3 时保存 pageList
|
||||
// status >= 3 时确保页面数据已保存
|
||||
if (remoteStatus >= LeaiApiClient.STATUS_COMPLETED) {
|
||||
savePageList(work.getId(), remoteData.get("pageList"));
|
||||
ensurePagesSaved(work.getId(), work.getRemoteWorkId(), remoteData.get("pageList"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -290,6 +296,42 @@ public class LeaiSyncService implements ILeaiSyncService {
|
||||
log.info("[乐读派] 保存作品页面数据: workId={}, 页数={}", workId, pageList.size());
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查本地是否已有页面记录
|
||||
*/
|
||||
private boolean hasPages(Long workId) {
|
||||
LambdaQueryWrapper<UgcWorkPage> wrapper = new LambdaQueryWrapper<>();
|
||||
wrapper.eq(UgcWorkPage::getWorkId, workId);
|
||||
return ugcWorkPageMapper.selectCount(wrapper) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保页面数据已保存
|
||||
* 如果传入的 pageListObj 不为空 → 直接保存
|
||||
* 如果为空 → 主动调用 B2 接口拉取完整数据,再保存页面
|
||||
*/
|
||||
private void ensurePagesSaved(Long workId, String remoteWorkId, Object pageListObj) {
|
||||
if (pageListObj != null) {
|
||||
savePageList(workId, pageListObj);
|
||||
return;
|
||||
}
|
||||
|
||||
// 本地已有页面数据,无需补充拉取
|
||||
if (hasPages(workId)) {
|
||||
log.debug("[乐读派] 本地已有页面数据,跳过补充拉取: workId={}", workId);
|
||||
return;
|
||||
}
|
||||
|
||||
// pageListObj 为空且本地无页面 → 主动拉取完整数据
|
||||
log.info("[乐读派] 页面数据缺失,主动拉取完整数据: workId={}, remoteWorkId={}", workId, remoteWorkId);
|
||||
Map<String, Object> fullData = leaiApiClient.fetchWorkDetail(remoteWorkId);
|
||||
if (fullData != null) {
|
||||
savePageList(workId, fullData.get("pageList"));
|
||||
} else {
|
||||
log.warn("[乐读派] 补充拉取页面数据失败: workId={}, remoteWorkId={}", workId, remoteWorkId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 remoteWorkId 查找本地作品
|
||||
*/
|
||||
|
||||
@ -16,7 +16,7 @@ import java.util.Map;
|
||||
/**
|
||||
* 定时任务:B3 对账 + Webhook 失败重试
|
||||
* <p>
|
||||
* 1. 每 30 分钟调用 B3 接口对账,补偿 Webhook 遗漏
|
||||
* 1. B3 对账:开发/测试环境每 1 分钟,生产环境每 30 分钟(配置项 leai.reconcile-interval)
|
||||
* 2. 每 10 分钟重试失败的 Webhook 事件(最多 3 次)
|
||||
*/
|
||||
@Slf4j
|
||||
@ -35,9 +35,9 @@ public class LeaiReconcileTask {
|
||||
|
||||
/**
|
||||
* B3 定时对账
|
||||
* 每 30 分钟执行,初始延迟 60 秒
|
||||
* 间隔由 leai.reconcile-interval 配置(开发/测试: 1分钟,生产: 30分钟)
|
||||
*/
|
||||
@Scheduled(fixedRate = 30 * 60 * 1000, initialDelay = 60 * 1000)
|
||||
@Scheduled(fixedRateString = "${leai.reconcile-interval:1800000}", initialDelayString = "${leai.reconcile-initial-delay:60000}")
|
||||
public void reconcile() {
|
||||
log.info("[B3对账] 开始执行...");
|
||||
|
||||
|
||||
@ -15,10 +15,4 @@ public class LeaiTokenVO {
|
||||
|
||||
@Schema(description = "机构ID(对应本项目的租户 code,即 tenant_code)")
|
||||
private String orgId;
|
||||
|
||||
@Schema(description = "H5 前端地址")
|
||||
private String h5Url;
|
||||
|
||||
@Schema(description = "用户手机号")
|
||||
private String phone;
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package com.competition.modules.pub.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 阿里云短信服务配置
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "aliyun.sms")
|
||||
public class SmsConfig {
|
||||
|
||||
/** 阿里云短信 AccessKey ID */
|
||||
private String accessKeyId;
|
||||
|
||||
/** 阿里云短信 AccessKey Secret */
|
||||
private String accessKeySecret;
|
||||
|
||||
/** 短信签名名称 */
|
||||
private String signName = "乐绘世界";
|
||||
|
||||
/** 验证码模板 CODE */
|
||||
private String templateCode;
|
||||
|
||||
/** 是否启用真实短信发送(false 时验证码输出到日志) */
|
||||
private boolean enabled = false;
|
||||
}
|
||||
@ -5,7 +5,9 @@ import com.competition.common.result.Result;
|
||||
import com.competition.common.util.SecurityUtil;
|
||||
import com.competition.modules.pub.dto.PublicLoginDto;
|
||||
import com.competition.modules.pub.dto.PublicRegisterDto;
|
||||
import com.competition.modules.pub.dto.SendSmsCodeDto;
|
||||
import com.competition.modules.pub.service.PublicAuthService;
|
||||
import com.competition.modules.pub.service.SmsCodeService;
|
||||
import com.competition.security.annotation.Public;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
@ -22,6 +24,7 @@ import java.util.Map;
|
||||
public class PublicAuthController {
|
||||
|
||||
private final PublicAuthService publicAuthService;
|
||||
private final SmsCodeService smsCodeService;
|
||||
|
||||
@Public
|
||||
@PostMapping("/register")
|
||||
@ -39,6 +42,15 @@ public class PublicAuthController {
|
||||
return Result.success(publicAuthService.login(dto));
|
||||
}
|
||||
|
||||
@Public
|
||||
@PostMapping("/sms/send")
|
||||
@RateLimit(permits = 1, duration = 60)
|
||||
@Operation(summary = "发送短信验证码")
|
||||
public Result<Void> sendSmsCode(@Valid @RequestBody SendSmsCodeDto dto) {
|
||||
smsCodeService.sendCode(dto.getPhone());
|
||||
return Result.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/switch-child")
|
||||
@Operation(summary = "切换到子女账号")
|
||||
public Result<Map<String, Object>> switchChild(@RequestBody Map<String, Long> body) {
|
||||
|
||||
@ -30,6 +30,11 @@ public class PublicRegisterDto {
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
|
||||
@NotBlank(message = "短信验证码不能为空")
|
||||
@Size(min = 6, max = 6, message = "验证码为6位数字")
|
||||
@Schema(description = "短信验证码")
|
||||
private String smsCode;
|
||||
|
||||
@Schema(description = "城市")
|
||||
private String city;
|
||||
}
|
||||
|
||||
@ -0,0 +1,19 @@
|
||||
package com.competition.modules.pub.dto;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Pattern;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 发送短信验证码请求
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "发送短信验证码请求")
|
||||
public class SendSmsCodeDto {
|
||||
|
||||
@NotBlank(message = "手机号不能为空")
|
||||
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "手机号格式不正确")
|
||||
@Schema(description = "手机号")
|
||||
private String phone;
|
||||
}
|
||||
@ -43,12 +43,16 @@ public class PublicAuthService {
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtUtil jwtUtil;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
private final SmsCodeService smsCodeService;
|
||||
|
||||
/**
|
||||
* 公众端注册
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> register(PublicRegisterDto dto) {
|
||||
// 校验短信验证码
|
||||
smsCodeService.verifyCode(dto.getPhone(), dto.getSmsCode());
|
||||
|
||||
// 查找公众租户
|
||||
SysTenant publicTenant = sysTenantMapper.selectOne(
|
||||
new LambdaQueryWrapper<SysTenant>().eq(SysTenant::getCode, TenantConstants.CODE_PUBLIC));
|
||||
|
||||
@ -0,0 +1,132 @@
|
||||
package com.competition.modules.pub.service;
|
||||
|
||||
import com.competition.common.constants.CacheConstants;
|
||||
import com.competition.common.enums.ErrorCode;
|
||||
import com.competition.common.exception.BusinessException;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 短信验证码业务服务
|
||||
* 负责:生成验证码、存储到 Redis、校验验证码
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmsCodeService {
|
||||
|
||||
private final SmsService smsService;
|
||||
private final StringRedisTemplate stringRedisTemplate;
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*
|
||||
* @param phone 手机号
|
||||
*/
|
||||
public void sendCode(String phone) {
|
||||
// 检查 60 秒发送间隔
|
||||
String intervalKey = CacheConstants.SMS_INTERVAL_PREFIX + phone;
|
||||
if (Boolean.TRUE.equals(stringRedisTemplate.hasKey(intervalKey))) {
|
||||
throw new BusinessException(ErrorCode.SMS_SEND_TOO_FREQUENT.getCode(),
|
||||
ErrorCode.SMS_SEND_TOO_FREQUENT.getMessage());
|
||||
}
|
||||
|
||||
// 检查每日发送次数上限
|
||||
String dailyKey = CacheConstants.SMS_DAILY_PREFIX + phone;
|
||||
String dailyCountStr = stringRedisTemplate.opsForValue().get(dailyKey);
|
||||
int dailyCount = dailyCountStr != null ? Integer.parseInt(dailyCountStr) : 0;
|
||||
if (dailyCount >= CacheConstants.SMS_DAILY_LIMIT) {
|
||||
throw new BusinessException(ErrorCode.SMS_DAILY_LIMIT_EXCEEDED.getCode(),
|
||||
ErrorCode.SMS_DAILY_LIMIT_EXCEEDED.getMessage());
|
||||
}
|
||||
|
||||
// 生成验证码
|
||||
String code = smsService.generateCode();
|
||||
|
||||
// 调用短信服务发送
|
||||
smsService.sendVerifyCode(phone, code);
|
||||
|
||||
// 存储验证码到 Redis(5 分钟有效)
|
||||
String codeKey = CacheConstants.SMS_CODE_PREFIX + phone;
|
||||
stringRedisTemplate.opsForValue().set(codeKey, code,
|
||||
Duration.ofMinutes(CacheConstants.SMS_CODE_EXPIRE_MINUTES));
|
||||
|
||||
// 设置发送间隔(60 秒)
|
||||
stringRedisTemplate.opsForValue().set(intervalKey, "1",
|
||||
Duration.ofSeconds(CacheConstants.SMS_INTERVAL_SECONDS));
|
||||
|
||||
// 递增每日发送次数(TTL 到当天 24:00)
|
||||
stringRedisTemplate.opsForValue().increment(dailyKey);
|
||||
if (dailyCount == 0) {
|
||||
// 首次发送,设置过期时间到当天结束
|
||||
long secondsUntilMidnight = Duration.between(
|
||||
LocalDateTime.now(),
|
||||
LocalDateTime.now().toLocalDate().plusDays(1).atTime(LocalTime.MIDNIGHT)
|
||||
).getSeconds();
|
||||
stringRedisTemplate.expire(dailyKey, secondsUntilMidnight, TimeUnit.SECONDS);
|
||||
}
|
||||
|
||||
log.info("验证码已发送:手机号={}", phone);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验短信验证码
|
||||
*
|
||||
* @param phone 手机号
|
||||
* @param code 用户输入的验证码
|
||||
* @return 校验是否通过
|
||||
*/
|
||||
public boolean verifyCode(String phone, String code) {
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.SMS_CODE_INVALID.getCode(),
|
||||
"验证码不能为空");
|
||||
}
|
||||
|
||||
String codeKey = CacheConstants.SMS_CODE_PREFIX + phone;
|
||||
String storedCode = stringRedisTemplate.opsForValue().get(codeKey);
|
||||
|
||||
// 验证码不存在或已过期
|
||||
if (storedCode == null) {
|
||||
throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED.getCode(),
|
||||
ErrorCode.SMS_CODE_EXPIRED.getMessage());
|
||||
}
|
||||
|
||||
// 验证码匹配
|
||||
if (storedCode.equals(code)) {
|
||||
// 验证成功:删除验证码(一次性使用)
|
||||
stringRedisTemplate.delete(codeKey);
|
||||
// 清除验证失败次数
|
||||
stringRedisTemplate.delete(CacheConstants.SMS_VERIFY_PREFIX + phone);
|
||||
log.info("验证码校验通过:手机号={}", phone);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 验证失败:递增失败次数
|
||||
String verifyKey = CacheConstants.SMS_VERIFY_PREFIX + phone;
|
||||
Long failCount = stringRedisTemplate.opsForValue().increment(verifyKey);
|
||||
if (failCount != null && failCount == 1) {
|
||||
// 首次失败,设置过期时间
|
||||
stringRedisTemplate.expire(verifyKey,
|
||||
Duration.ofMinutes(CacheConstants.SMS_CODE_EXPIRE_MINUTES));
|
||||
}
|
||||
|
||||
// 失败次数达到上限,删除验证码
|
||||
if (failCount != null && failCount >= CacheConstants.SMS_VERIFY_FAIL_LIMIT) {
|
||||
stringRedisTemplate.delete(codeKey);
|
||||
stringRedisTemplate.delete(verifyKey);
|
||||
log.warn("验证码错误次数超限,已清除:手机号={}", phone);
|
||||
throw new BusinessException(ErrorCode.SMS_CODE_EXPIRED.getCode(),
|
||||
"验证码错误次数过多,请重新获取验证码");
|
||||
}
|
||||
|
||||
throw new BusinessException(ErrorCode.SMS_CODE_INVALID.getCode(),
|
||||
ErrorCode.SMS_CODE_INVALID.getMessage());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
package com.competition.modules.pub.service;
|
||||
|
||||
import cn.hutool.core.util.RandomUtil;
|
||||
import com.competition.modules.pub.config.SmsConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* 短信发送服务
|
||||
* 封装阿里云短信 SDK,开发模式下验证码输出到日志
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SmsService {
|
||||
|
||||
private final SmsConfig smsConfig;
|
||||
|
||||
/**
|
||||
* 发送验证码短信
|
||||
*
|
||||
* @param phone 手机号
|
||||
* @param code 验证码
|
||||
*/
|
||||
public void sendVerifyCode(String phone, String code) {
|
||||
if (!smsConfig.isEnabled()) {
|
||||
// 开发模式:验证码输出到日志
|
||||
log.info("【开发模式】短信验证码:手机号={}, 验证码={}", phone, code);
|
||||
return;
|
||||
}
|
||||
|
||||
// 生产模式:调用阿里云短信 SDK
|
||||
try {
|
||||
com.aliyun.dysmsapi20170525.Client client = createClient();
|
||||
com.aliyun.dysmsapi20170525.models.SendSmsRequest request =
|
||||
new com.aliyun.dysmsapi20170525.models.SendSmsRequest()
|
||||
.setPhoneNumbers(phone)
|
||||
.setSignName(smsConfig.getSignName())
|
||||
.setTemplateCode(smsConfig.getTemplateCode())
|
||||
.setTemplateParam("{\"code\":\"" + code + "\"}");
|
||||
|
||||
com.aliyun.dysmsapi20170525.models.SendSmsResponse response = client.sendSms(request);
|
||||
|
||||
if (response.getBody() == null || !"OK".equals(response.getBody().getCode())) {
|
||||
String errMsg = response.getBody() != null
|
||||
? response.getBody().getMessage() : "响应为空";
|
||||
log.error("短信发送失败:手机号={}, 错误码={}, 错误信息={}",
|
||||
phone,
|
||||
response.getBody() != null ? response.getBody().getCode() : "null",
|
||||
errMsg);
|
||||
throw new RuntimeException("短信发送失败:" + errMsg);
|
||||
}
|
||||
|
||||
log.info("短信发送成功:手机号={}", phone);
|
||||
} catch (RuntimeException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("短信发送异常:手机号={}", phone, e);
|
||||
throw new RuntimeException("短信发送异常", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成6位数字验证码
|
||||
*/
|
||||
public String generateCode() {
|
||||
return RandomUtil.randomNumbers(6);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建阿里云短信客户端
|
||||
*/
|
||||
private com.aliyun.dysmsapi20170525.Client createClient() throws Exception {
|
||||
com.aliyun.teaopenapi.models.Config config = new com.aliyun.teaopenapi.models.Config()
|
||||
.setAccessKeyId(smsConfig.getAccessKeyId())
|
||||
.setAccessKeySecret(smsConfig.getAccessKeySecret());
|
||||
config.endpoint = "dysmsapi.aliyuncs.com";
|
||||
return new com.aliyun.dysmsapi20170525.Client(config);
|
||||
}
|
||||
}
|
||||
@ -34,7 +34,7 @@ public class SysUser extends BaseEntity {
|
||||
@Schema(description = "邮箱")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "手机号(全局唯一)")
|
||||
@Schema(description = "手机号(租户内唯一)")
|
||||
private String phone;
|
||||
|
||||
@Schema(description = "微信OpenID")
|
||||
|
||||
@ -48,6 +48,13 @@ aliyun:
|
||||
# 前端直传跨域:启动时自动配置 OSS CORS
|
||||
cors-enabled: ${OSS_CORS_ENABLED:true}
|
||||
cors-allowed-origins: ${OSS_CORS_ORIGINS:*}
|
||||
# 短信服务配置(开发环境关闭真实发送,验证码输出到日志)
|
||||
sms:
|
||||
access-key-id: ${SMS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||
access-key-secret: ${SMS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
sign-name: ${SMS_SIGN_NAME:乐读派}
|
||||
template-code: ${SMS_TEMPLATE_CODE:SMS_490225426}
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
@ -62,4 +69,5 @@ leai:
|
||||
org-id: ${LEAI_ORG_ID:gdlib}
|
||||
app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y}
|
||||
api-url: ${LEAI_API_URL:http://192.168.1.120:8080}
|
||||
h5-url: ${LEAI_H5_URL:http://192.168.1.120:3001}
|
||||
reconcile-interval: 60000 # B3对账间隔:1分钟(开发环境)
|
||||
reconcile-initial-delay: 30000 # 初始延迟:30秒
|
||||
|
||||
@ -43,6 +43,14 @@ logging:
|
||||
level:
|
||||
com.competition: info
|
||||
|
||||
# 乐读派 AI 创作系统配置
|
||||
leai:
|
||||
org-id: ${LEAI_ORG_ID:gdlib}
|
||||
app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y}
|
||||
api-url: ${LEAI_API_URL:http://192.168.1.120:8080}
|
||||
reconcile-interval: 1800000 # B3对账间隔:30分钟(生产环境)
|
||||
reconcile-initial-delay: 60000 # 初始延迟:60秒
|
||||
|
||||
# 阿里云 OSS 配置(开发环境)
|
||||
aliyun:
|
||||
oss:
|
||||
@ -54,3 +62,10 @@ aliyun:
|
||||
# 前端直传跨域:启动时自动配置 OSS CORS
|
||||
cors-enabled: ${OSS_CORS_ENABLED:true}
|
||||
cors-allowed-origins: ${OSS_CORS_ORIGINS:*}
|
||||
# 短信服务配置(生产环境)
|
||||
sms:
|
||||
access-key-id: ${SMS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||
access-key-secret: ${SMS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
sign-name: ${SMS_SIGN_NAME:乐读派}
|
||||
template-code: ${SMS_TEMPLATE_CODE:SMS_490225426}
|
||||
enabled: true
|
||||
|
||||
@ -44,6 +44,13 @@ aliyun:
|
||||
# 前端直传跨域:启动时自动配置 OSS CORS
|
||||
cors-enabled: ${OSS_CORS_ENABLED:true}
|
||||
cors-allowed-origins: ${OSS_CORS_ORIGINS:*}
|
||||
# 短信服务配置(测试环境)
|
||||
sms:
|
||||
access-key-id: ${SMS_ACCESS_KEY_ID:LTAI5tKZhPofbThbSzDSiWoK}
|
||||
access-key-secret: ${SMS_ACCESS_KEY_SECRET:FtcsC7oQX3T0NaChaa9FYq2aoysQFM}
|
||||
sign-name: ${SMS_SIGN_NAME:乐读派}
|
||||
template-code: ${SMS_TEMPLATE_CODE:SMS_490225426}
|
||||
enabled: true
|
||||
|
||||
logging:
|
||||
level:
|
||||
@ -54,4 +61,5 @@ leai:
|
||||
org-id: ${LEAI_ORG_ID:gdlib}
|
||||
app-secret: ${LEAI_APP_SECRET:leai_mnoi9q1a_mtcawrn8y}
|
||||
api-url: ${LEAI_API_URL:http://192.168.1.120:8080}
|
||||
h5-url: ${LEAI_H5_URL:http://192.168.1.120:3001}
|
||||
reconcile-interval: 60000 # B3对账间隔:1分钟(测试环境)
|
||||
reconcile-initial-delay: 30000 # 初始延迟:30秒
|
||||
|
||||
@ -0,0 +1,43 @@
|
||||
-- V13: 修复 t_sys_user 唯一索引 — phone/username 改为 (tenant_id, ...) 联合唯一
|
||||
-- 日期: 2026-04-09
|
||||
-- 背景: 多租户场景下同一手机号/用户名应可在不同租户下注册,需改为联合唯一索引
|
||||
|
||||
-- =====================================================
|
||||
-- 1. phone: 删除旧的独立唯一索引,创建 (tenant_id, phone) 联合唯一索引
|
||||
-- =====================================================
|
||||
|
||||
-- 安全删除已存在的 phone 唯一索引(索引名可能是 phone 或其他自定义名)
|
||||
SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 't_sys_user'
|
||||
AND INDEX_NAME = 'phone'
|
||||
AND NON_UNIQUE = 0);
|
||||
SET @sqlstmt := IF(@exist > 0,
|
||||
'ALTER TABLE t_sys_user DROP INDEX phone',
|
||||
'SELECT ''phone 唯一索引不存在,跳过删除''');
|
||||
PREPARE stmt FROM @sqlstmt;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 创建联合唯一索引(IF NOT EXISTS 防止重复创建)
|
||||
CREATE UNIQUE INDEX uk_sys_user_tenant_phone ON t_sys_user(tenant_id, phone);
|
||||
|
||||
-- =====================================================
|
||||
-- 2. username: 删除旧的独立唯一索引,创建 (tenant_id, username) 联合唯一索引
|
||||
-- =====================================================
|
||||
|
||||
-- 安全删除已存在的 username 唯一索引
|
||||
SET @exist := (SELECT COUNT(*) FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 't_sys_user'
|
||||
AND INDEX_NAME = 'username'
|
||||
AND NON_UNIQUE = 0);
|
||||
SET @sqlstmt := IF(@exist > 0,
|
||||
'ALTER TABLE t_sys_user DROP INDEX username',
|
||||
'SELECT ''username 唯一索引不存在,跳过删除''');
|
||||
PREPARE stmt FROM @sqlstmt;
|
||||
EXECUTE stmt;
|
||||
DEALLOCATE PREPARE stmt;
|
||||
|
||||
-- 创建联合唯一索引
|
||||
CREATE UNIQUE INDEX uk_sys_user_tenant_username ON t_sys_user(tenant_id, username);
|
||||
@ -3,9 +3,8 @@ import { test, expect } from '../fixtures/auth.fixture'
|
||||
/**
|
||||
* P0: 认证 API 测试
|
||||
*
|
||||
* 测试 LeaiAuthController 的三个接口:
|
||||
* 测试 LeaiAuthController 的两个接口:
|
||||
* - GET /leai-auth/token(iframe 主入口)
|
||||
* - GET /leai-auth(302 重定向)
|
||||
* - GET /leai-auth/refresh-token(Token 刷新)
|
||||
*/
|
||||
|
||||
@ -19,7 +18,7 @@ test.describe('乐读派认证 API', () => {
|
||||
expect(resp.status()).toBe(401)
|
||||
})
|
||||
|
||||
test('已登录 — 返回 token + orgId + h5Url + phone', async ({ authedApi }) => {
|
||||
test('已登录 — 返回 token + orgId', async ({ authedApi }) => {
|
||||
const resp = await authedApi.get(`${API_BASE}/leai-auth/token`)
|
||||
expect(resp.status()).toBe(200)
|
||||
|
||||
@ -30,11 +29,8 @@ test.describe('乐读派认证 API', () => {
|
||||
const data = json.data
|
||||
expect(data).toHaveProperty('token')
|
||||
expect(data).toHaveProperty('orgId')
|
||||
expect(data).toHaveProperty('h5Url')
|
||||
expect(data).toHaveProperty('phone')
|
||||
expect(data.token).toBeTruthy()
|
||||
expect(data.orgId).toBeTruthy()
|
||||
expect(data.h5Url).toContain('http')
|
||||
})
|
||||
|
||||
test('返回的 token 为非空字符串', async ({ authedApi }) => {
|
||||
@ -59,7 +55,6 @@ test.describe('乐读派认证 API', () => {
|
||||
expect(json.code).toBe(200)
|
||||
expect(json.data).toHaveProperty('token')
|
||||
expect(json.data).toHaveProperty('orgId')
|
||||
expect(json.data).toHaveProperty('phone')
|
||||
})
|
||||
|
||||
test('连续两次刷新返回不同 token', async ({ authedApi }) => {
|
||||
@ -78,38 +73,4 @@ test.describe('乐读派认证 API', () => {
|
||||
expect(json1.data.token).not.toBe(json2.data.token)
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('GET /leai-auth(302 重定向)', () => {
|
||||
test('未登录 — 返回 401', async ({ request }) => {
|
||||
const resp = await request.get(`${API_BASE}/leai-auth`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
// 可能是 401 或 302 到登录页
|
||||
expect([302, 401]).toContain(resp.status())
|
||||
})
|
||||
|
||||
test('已登录 — 302 重定向到 H5', async ({ authedApi }) => {
|
||||
const resp = await authedApi.get(`${API_BASE}/leai-auth`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(resp.status()).toBe(302)
|
||||
|
||||
const location = resp.headers()['location']
|
||||
expect(location).toBeDefined()
|
||||
expect(location).toContain('token=')
|
||||
expect(location).toContain('orgId=')
|
||||
expect(location).toContain('phone=')
|
||||
})
|
||||
|
||||
test('带 returnPath — 重定向 URL 包含 returnPath', async ({ authedApi }) => {
|
||||
const resp = await authedApi.get(`${API_BASE}/leai-auth?returnPath=/edit-info/test123`, {
|
||||
maxRedirects: 0,
|
||||
})
|
||||
expect(resp.status()).toBe(302)
|
||||
|
||||
const location = resp.headers()['location']
|
||||
expect(location).toContain('returnPath=')
|
||||
expect(location).toContain('edit-info')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -56,7 +56,6 @@ test.describe('创作页 iframe 嵌入', () => {
|
||||
data: {
|
||||
token: 'mock_session_token_xxx',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
@ -106,7 +105,6 @@ test.describe('创作页 iframe 嵌入', () => {
|
||||
data: {
|
||||
token: 'mock_token',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
@ -175,7 +173,6 @@ test.describe('创作页 iframe 嵌入', () => {
|
||||
data: {
|
||||
token: 'retry_token_success',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
@ -216,7 +213,6 @@ test.describe('创作页 iframe 嵌入', () => {
|
||||
data: {
|
||||
token: 'mock_token',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -25,7 +25,6 @@ test.describe('端到端:创作完整流程', () => {
|
||||
data: {
|
||||
token: 'e2e_test_token',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
@ -100,7 +99,7 @@ test.describe('端到端:创作完整流程', () => {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 200,
|
||||
data: { token: 'initial_token', orgId: 'gdlib', h5Url: 'http://localhost:3001', phone: '13800001111' },
|
||||
data: { token: 'initial_token', orgId: 'gdlib', phone: '13800001111' },
|
||||
}),
|
||||
})
|
||||
})
|
||||
@ -198,7 +197,7 @@ test.describe('端到端:创作完整流程', () => {
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({
|
||||
code: 200,
|
||||
data: { token: 'retry_token', orgId: 'gdlib', h5Url: 'http://localhost:3001', phone: '13800001111' },
|
||||
data: { token: 'retry_token', orgId: 'gdlib', phone: '13800001111' },
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
@ -28,7 +28,6 @@ async function setupMockRoutes(page: import('@playwright/test').Page) {
|
||||
data: {
|
||||
token: 'mock_keepalive_token',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -49,7 +49,6 @@ test.describe('postMessage 通信', () => {
|
||||
data: {
|
||||
token: 'mock_token_for_postmessage_test',
|
||||
orgId: 'gdlib',
|
||||
h5Url: 'http://localhost:3001',
|
||||
phone: '13800001111',
|
||||
},
|
||||
}),
|
||||
|
||||
@ -10,7 +10,6 @@ export const LEAI_TEST_CONFIG = {
|
||||
orgId: 'gdlib',
|
||||
appSecret: 'leai_mnoi9q1a_mtcawrn8y',
|
||||
apiUrl: 'http://192.168.1.120:8080',
|
||||
h5Url: 'http://192.168.1.120:3001',
|
||||
testPhone: '13800001111',
|
||||
}
|
||||
|
||||
|
||||
@ -1,122 +1,23 @@
|
||||
/**
|
||||
* AI 创作 API 层
|
||||
* 从 lesingle-aicreate-client/src/api/index.js 迁移
|
||||
*
|
||||
* 独立 axios 实例,直连乐读派后端(VITE_LEAI_API_URL + /api/v1)
|
||||
* 认证:Bearer sessionToken(企业模式)
|
||||
* 所有请求经由后端代理 /leai-proxy/* 转发到乐读派
|
||||
* 前端不持有 phone/orgId/appSecret,仅通过 JWT 认证调后端代理
|
||||
* 后端自动注入 orgId + phone 并使用 HMAC 签名
|
||||
*/
|
||||
import axios from 'axios'
|
||||
import OSS from 'ali-oss'
|
||||
import { signRequest } from '@/utils/aicreate/hmac'
|
||||
import { useAicreateStore } from '@/stores/aicreate'
|
||||
import { leaiApi } from '@/api/public'
|
||||
import publicApi from '@/api/public'
|
||||
import type { StsTokenData, CreateStoryParams } from './types'
|
||||
|
||||
// 乐读派后端地址(从环境变量读取,直连,不走代理)
|
||||
const leaiBaseUrl = import.meta.env.VITE_LEAI_API_URL || ''
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: leaiBaseUrl ? leaiBaseUrl + '/api/v1' : '/api/v1',
|
||||
timeout: 120000
|
||||
})
|
||||
|
||||
// ─── 请求拦截器:双模式认证 ───
|
||||
api.interceptors.request.use((config) => {
|
||||
// 需要从 store 获取最新状态,不能用模块级缓存
|
||||
const store = useAicreateStore()
|
||||
if (store.sessionToken) {
|
||||
config.headers['Authorization'] = 'Bearer ' + store.sessionToken
|
||||
} else if (store.orgId && store.appSecret) {
|
||||
const queryParams: Record<string, string> = {}
|
||||
if (config.params) {
|
||||
Object.entries(config.params).forEach(([k, v]) => {
|
||||
if (v != null) queryParams[k] = String(v)
|
||||
})
|
||||
}
|
||||
const headers = signRequest(store.orgId, store.appSecret, queryParams)
|
||||
Object.assign(config.headers, headers)
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// ─── Token 刷新状态管理 ───
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<(token: string | null) => void> = []
|
||||
|
||||
async function handleTokenExpired(failedConfig: any): Promise<any> {
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true
|
||||
try {
|
||||
const data = await leaiApi.refreshToken()
|
||||
const store = useAicreateStore()
|
||||
store.setSession(data.orgId || store.orgId, data.token)
|
||||
if (data.phone) store.setPhone(data.phone)
|
||||
isRefreshing = false
|
||||
pendingRequests.forEach(cb => cb(data.token))
|
||||
pendingRequests = []
|
||||
} catch {
|
||||
isRefreshing = false
|
||||
pendingRequests.forEach(cb => cb(null))
|
||||
pendingRequests = []
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
if (pendingRequests.length >= 20) {
|
||||
reject(new Error('TOO_MANY_PENDING_REQUESTS'))
|
||||
return
|
||||
}
|
||||
pendingRequests.push((newToken) => {
|
||||
if (newToken) {
|
||||
if (failedConfig.__retried) {
|
||||
reject(new Error('TOKEN_REFRESH_FAILED'))
|
||||
return
|
||||
}
|
||||
failedConfig.__retried = true
|
||||
failedConfig.headers['Authorization'] = 'Bearer ' + newToken
|
||||
delete failedConfig.headers['X-App-Key']
|
||||
delete failedConfig.headers['X-Timestamp']
|
||||
delete failedConfig.headers['X-Nonce']
|
||||
delete failedConfig.headers['X-Signature']
|
||||
resolve(api(failedConfig))
|
||||
} else {
|
||||
reject(new Error('TOKEN_REFRESH_FAILED'))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── 响应拦截器 ───
|
||||
api.interceptors.response.use(
|
||||
(res) => {
|
||||
const d = res.data
|
||||
if (d?.code !== 0 && d?.code !== 200) {
|
||||
const store = useAicreateStore()
|
||||
// Token 过期
|
||||
if (store.sessionToken && (d?.code === 20010 || d?.code === 20009)) {
|
||||
return handleTokenExpired(res.config)
|
||||
}
|
||||
return Promise.reject(new Error(d?.msg || '请求失败'))
|
||||
}
|
||||
return d
|
||||
},
|
||||
(err) => {
|
||||
const store = useAicreateStore()
|
||||
if (store.sessionToken && err.response?.status === 401) {
|
||||
return handleTokenExpired(err.config)
|
||||
}
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// API 函数
|
||||
// API 函数(全部走后端代理)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** 图片上传 */
|
||||
export function uploadImage(file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post('/creation/upload', form, {
|
||||
return publicApi.post('/leai-proxy/upload', form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 30000
|
||||
})
|
||||
@ -124,23 +25,15 @@ export function uploadImage(file: File) {
|
||||
|
||||
/** 角色提取 */
|
||||
export function extractCharacters(imageUrl: string, opts: { saveOriginal?: boolean; title?: string } = {}) {
|
||||
const store = useAicreateStore()
|
||||
const body: Record<string, any> = {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
imageUrl
|
||||
}
|
||||
const body: Record<string, any> = { imageUrl }
|
||||
if (opts.saveOriginal) body.saveOriginal = true
|
||||
if (opts.title) body.title = opts.title
|
||||
return api.post('/creation/extract-original', body, { timeout: 120000 })
|
||||
return publicApi.post('/leai-proxy/extract', body, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** 图片故事创作 */
|
||||
export function createStory(params: CreateStoryParams) {
|
||||
const store = useAicreateStore()
|
||||
const body: Record<string, any> = {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
imageUrl: params.imageUrl,
|
||||
storyHint: params.storyHint,
|
||||
style: params.style,
|
||||
@ -151,41 +44,27 @@ export function createStory(params: CreateStoryParams) {
|
||||
if (params.author) body.author = params.author
|
||||
if (params.heroCharId) body.heroCharId = params.heroCharId
|
||||
if (params.extractId) body.extractId = params.extractId
|
||||
return api.post('/creation/image-story', body)
|
||||
return publicApi.post('/leai-proxy/create-story', body)
|
||||
}
|
||||
|
||||
/** 查询作品详情 */
|
||||
export function getWorkDetail(workId: string) {
|
||||
const store = useAicreateStore()
|
||||
return api.get(`/query/work/${workId}`, {
|
||||
params: { orgId: store.orgId, phone: store.phone }
|
||||
})
|
||||
return publicApi.get(`/leai-proxy/work/${workId}`)
|
||||
}
|
||||
|
||||
/** 额度校验 */
|
||||
export function checkQuota() {
|
||||
const store = useAicreateStore()
|
||||
return api.post('/query/validate', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
apiType: 'A3'
|
||||
})
|
||||
return publicApi.post('/leai-proxy/validate', { apiType: 'A3' })
|
||||
}
|
||||
|
||||
/** 编辑绘本信息 */
|
||||
export function updateWork(workId: string, data: Record<string, any>) {
|
||||
return api.put(`/update/work/${workId}`, data)
|
||||
return publicApi.put(`/leai-proxy/work/${workId}`, data)
|
||||
}
|
||||
|
||||
/** 批量更新配音 URL */
|
||||
export function batchUpdateAudio(workId: string, pages: any[]) {
|
||||
const store = useAicreateStore()
|
||||
return api.post('/update/batch-audio', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
workId,
|
||||
pages
|
||||
})
|
||||
return publicApi.post('/leai-proxy/batch-audio', { workId, pages })
|
||||
}
|
||||
|
||||
/** 完成配音 */
|
||||
@ -195,24 +74,15 @@ export function finishDubbing(workId: string) {
|
||||
|
||||
/** AI 配音 */
|
||||
export function voicePage(data: Record<string, any>) {
|
||||
const store = useAicreateStore()
|
||||
return api.post('/creation/voice', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone,
|
||||
...data
|
||||
}, { timeout: 120000 })
|
||||
return publicApi.post('/leai-proxy/voice', data, { timeout: 120000 })
|
||||
}
|
||||
|
||||
/** STS 临时凭证 */
|
||||
/** STS 临时凭证(经后端代理获取) */
|
||||
export function getStsToken() {
|
||||
const store = useAicreateStore()
|
||||
return api.post('/oss/sts-token', {
|
||||
orgId: store.orgId,
|
||||
phone: store.phone
|
||||
})
|
||||
return publicApi.post('/leai-proxy/sts-token')
|
||||
}
|
||||
|
||||
// ─── OSS 直传 ───
|
||||
// ─── OSS 直传 ──
|
||||
let _ossClient: OSS | null = null
|
||||
let _stsData: StsTokenData | null = null
|
||||
|
||||
@ -224,7 +94,7 @@ async function getOssClient() {
|
||||
}
|
||||
}
|
||||
const res = await getStsToken()
|
||||
_stsData = res.data
|
||||
_stsData = res as StsTokenData
|
||||
_ossClient = new OSS({
|
||||
region: _stsData.region,
|
||||
accessKeyId: _stsData.accessKeyId,
|
||||
@ -234,7 +104,7 @@ async function getOssClient() {
|
||||
endpoint: _stsData.endpoint,
|
||||
refreshSTSToken: async () => {
|
||||
const r = await getStsToken()
|
||||
_stsData = r.data
|
||||
_stsData = r as StsTokenData
|
||||
return {
|
||||
accessKeyId: _stsData.accessKeyId,
|
||||
accessKeySecret: _stsData.accessKeySecret,
|
||||
@ -284,5 +154,3 @@ export async function ossListFiles() {
|
||||
url: obj.url
|
||||
}))
|
||||
}
|
||||
|
||||
export default api
|
||||
|
||||
@ -45,11 +45,17 @@ function isTokenExpired(token: string): boolean {
|
||||
publicApi.interceptors.response.use(
|
||||
(response) => {
|
||||
// 后端返回格式:{ code: 200, message: "success", data: xxx }
|
||||
// 当 data 为 null 时,直接返回 null
|
||||
if (response.data) {
|
||||
return response.data.data !== undefined ? response.data.data : response.data
|
||||
// 检查业务状态码,非 200 视为业务错误
|
||||
const resData = response.data
|
||||
if (resData && resData.code !== undefined && resData.code !== 200) {
|
||||
const error: any = new Error(resData.message || "请求失败")
|
||||
error.response = { data: resData }
|
||||
return Promise.reject(error)
|
||||
}
|
||||
return response.data
|
||||
if (resData) {
|
||||
return resData.data !== undefined ? resData.data : resData
|
||||
}
|
||||
return resData
|
||||
},
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
@ -71,6 +77,7 @@ export interface PublicRegisterParams {
|
||||
password: string
|
||||
nickname: string
|
||||
phone?: string
|
||||
smsCode?: string
|
||||
city?: string
|
||||
}
|
||||
|
||||
@ -108,6 +115,10 @@ export const publicAuthApi = {
|
||||
|
||||
login: (data: PublicLoginParams): Promise<LoginResponse> =>
|
||||
publicApi.post("/public/auth/login", data),
|
||||
|
||||
/** 发送短信验证码 */
|
||||
sendSmsCode: (phone: string): Promise<void> =>
|
||||
publicApi.post("/public/auth/sms/send", { phone }),
|
||||
}
|
||||
|
||||
// ==================== 个人信息 ====================
|
||||
@ -503,15 +514,12 @@ export const leaiApi = {
|
||||
getToken: (): Promise<{
|
||||
token: string
|
||||
orgId: string
|
||||
h5Url: string
|
||||
phone: string
|
||||
}> => publicApi.get("/leai-auth/token"),
|
||||
|
||||
// 刷新 Token(TOKEN_EXPIRED 时调用)
|
||||
refreshToken: (): Promise<{
|
||||
token: string
|
||||
orgId: string
|
||||
phone: string
|
||||
}> => publicApi.get("/leai-auth/refresh-token"),
|
||||
}
|
||||
|
||||
|
||||
@ -108,7 +108,14 @@ const aicreateStore = useAicreateStore()
|
||||
const isLoggedIn = computed(() => !!localStorage.getItem("public_token"))
|
||||
const user = computed(() => {
|
||||
const data = localStorage.getItem("public_user")
|
||||
return data ? JSON.parse(data) : null
|
||||
if (!data || data === "undefined" || data === "null") return null
|
||||
try {
|
||||
return JSON.parse(data)
|
||||
} catch {
|
||||
// 数据损坏时清除并返回 null
|
||||
localStorage.removeItem("public_user")
|
||||
return null
|
||||
}
|
||||
})
|
||||
const userAvatar = computed(() => user.value?.avatar || undefined)
|
||||
|
||||
|
||||
@ -1,17 +1,15 @@
|
||||
/**
|
||||
* AI 创作全局状态(Pinia Store)
|
||||
*
|
||||
* 从 lesingle-aicreate-client/utils/store.js 迁移
|
||||
* 保留原有字段和方法,适配 Pinia setup 语法
|
||||
* 敏感信息(phone/orgId/appSecret)不再存储在 localStorage
|
||||
* orgId 仅存 sessionStorage(会话级),sessionToken 判断是否已初始化
|
||||
*/
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const useAicreateStore = defineStore('aicreate', () => {
|
||||
// ─── 认证信息 ───
|
||||
const phone = ref(localStorage.getItem('le_phone') || '')
|
||||
const orgId = ref(localStorage.getItem('le_orgId') || '')
|
||||
const appSecret = ref(localStorage.getItem('le_appSecret') || '')
|
||||
// ─── 认证信息(不再存储敏感信息到 localStorage) ───
|
||||
const orgId = ref(sessionStorage.getItem('le_orgId') || '')
|
||||
const sessionToken = ref(sessionStorage.getItem('le_sessionToken') || '')
|
||||
|
||||
// ─── 创作流程数据 ───
|
||||
@ -23,35 +21,23 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
||||
const storyData = ref<any>(null)
|
||||
const workId = ref('')
|
||||
const workDetail = ref<any>(null)
|
||||
const authRedirectUrl = ref('')
|
||||
|
||||
// ─── Tab 切换状态保存 ───
|
||||
const lastCreateRoute = ref('')
|
||||
|
||||
// ─── 方法 ───
|
||||
function setPhone(val: string) {
|
||||
phone.value = val
|
||||
localStorage.setItem('le_phone', val)
|
||||
}
|
||||
|
||||
function setOrg(id: string, secret: string) {
|
||||
orgId.value = id
|
||||
appSecret.value = secret
|
||||
localStorage.setItem('le_orgId', id)
|
||||
localStorage.setItem('le_appSecret', secret)
|
||||
}
|
||||
|
||||
function setSession(id: string, token: string) {
|
||||
orgId.value = id
|
||||
sessionToken.value = token
|
||||
localStorage.setItem('le_orgId', id)
|
||||
sessionStorage.setItem('le_orgId', id)
|
||||
sessionStorage.setItem('le_sessionToken', token)
|
||||
}
|
||||
|
||||
function clearSession() {
|
||||
sessionToken.value = ''
|
||||
orgId.value = ''
|
||||
sessionStorage.removeItem('le_sessionToken')
|
||||
sessionStorage.removeItem('le_orgId')
|
||||
}
|
||||
|
||||
function setLastCreateRoute(path: string) {
|
||||
@ -72,11 +58,8 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
||||
workId.value = ''
|
||||
workDetail.value = null
|
||||
lastCreateRoute.value = ''
|
||||
// 清除所有 localStorage 中的创作相关数据
|
||||
// 只清除创作流程数据,保留认证信息
|
||||
localStorage.removeItem('le_workId')
|
||||
localStorage.removeItem('le_phone')
|
||||
localStorage.removeItem('le_orgId')
|
||||
localStorage.removeItem('le_appSecret')
|
||||
// 清除 sessionStorage 中的恢复数据
|
||||
sessionStorage.removeItem('le_recovery')
|
||||
}
|
||||
@ -116,8 +99,8 @@ export const useAicreateStore = defineStore('aicreate', () => {
|
||||
|
||||
return {
|
||||
// 认证
|
||||
phone, orgId, appSecret, sessionToken, authRedirectUrl,
|
||||
setPhone, setOrg, setSession, clearSession,
|
||||
orgId, sessionToken,
|
||||
setSession, clearSession,
|
||||
// 创作流程
|
||||
imageUrl, extractId, characters, selectedCharacter,
|
||||
selectedStyle, storyData, workId, workDetail,
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
/**
|
||||
* HMAC-SHA256 签名工具
|
||||
* 从 lesingle-aicreate-client/utils/hmac.js 迁移
|
||||
*
|
||||
* 签名规则:排序的 query params + nonce + timestamp,用 & 拼接
|
||||
* POST JSON body 不参与签名
|
||||
*/
|
||||
import CryptoJS from 'crypto-js'
|
||||
|
||||
export function signRequest(orgId: string, appSecret: string, queryParams: Record<string, string> = {}) {
|
||||
const timestamp = String(Date.now())
|
||||
const nonce = Math.random().toString(36).substring(2, 15) + Date.now().toString(36)
|
||||
|
||||
const allParams: Record<string, string> = { ...queryParams, nonce, timestamp }
|
||||
const sorted = Object.keys(allParams).sort()
|
||||
const signStr = sorted.map(k => `${k}=${allParams[k]}`).join('&')
|
||||
|
||||
const signature = CryptoJS.HmacSHA256(signStr, appSecret).toString(CryptoJS.enc.Hex)
|
||||
|
||||
return {
|
||||
'X-App-Key': orgId,
|
||||
'X-Timestamp': timestamp,
|
||||
'X-Nonce': nonce,
|
||||
'X-Signature': signature
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,27 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="isRegister" name="smsCode" label="验证码">
|
||||
<div style="display: flex; gap: 8px">
|
||||
<a-input
|
||||
v-model:value="form.smsCode"
|
||||
placeholder="请输入6位验证码"
|
||||
size="large"
|
||||
:maxlength="6"
|
||||
style="flex: 1"
|
||||
/>
|
||||
<a-button
|
||||
size="large"
|
||||
:disabled="smsCountdown > 0 || !isPhoneValid"
|
||||
:loading="smsSending"
|
||||
@click="handleSendSms"
|
||||
style="min-width: 120px"
|
||||
>
|
||||
{{ smsCountdown > 0 ? `${smsCountdown}s 后重发` : "获取验证码" }}
|
||||
</a-button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="username" label="用户名">
|
||||
<a-input
|
||||
v-model:value="form.username"
|
||||
@ -93,7 +114,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed } from "vue"
|
||||
import { ref, reactive, computed, onUnmounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import { publicAuthApi } from "@/api/public"
|
||||
@ -110,6 +131,42 @@ const form = reactive({
|
||||
confirmPassword: "",
|
||||
nickname: "",
|
||||
phone: import.meta.env.DEV ? "13800138000" : "",
|
||||
smsCode: "",
|
||||
})
|
||||
|
||||
// 短信验证码相关
|
||||
const smsCountdown = ref(0)
|
||||
const smsSending = ref(false)
|
||||
let smsTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const isPhoneValid = computed(() => /^1[3-9]\d{9}$/.test(form.phone))
|
||||
|
||||
const handleSendSms = async () => {
|
||||
if (!isPhoneValid.value || smsCountdown.value > 0) return
|
||||
smsSending.value = true
|
||||
try {
|
||||
await publicAuthApi.sendSmsCode(form.phone)
|
||||
message.success("验证码已发送")
|
||||
// 开始 60 秒倒计时
|
||||
smsCountdown.value = 60
|
||||
smsTimer = setInterval(() => {
|
||||
smsCountdown.value--
|
||||
if (smsCountdown.value <= 0) {
|
||||
if (smsTimer) clearInterval(smsTimer)
|
||||
smsTimer = null
|
||||
}
|
||||
}, 1000)
|
||||
} catch (error: any) {
|
||||
const msg = error?.response?.data?.message || "验证码发送失败"
|
||||
message.error(msg)
|
||||
} finally {
|
||||
smsSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件销毁时清理定时器
|
||||
onUnmounted(() => {
|
||||
if (smsTimer) clearInterval(smsTimer)
|
||||
})
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
@ -141,6 +198,11 @@ const rules: Record<string, Rule[]> = {
|
||||
{ required: true, message: "请输入手机号", trigger: "blur" },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: "手机号格式不正确", trigger: "blur" },
|
||||
],
|
||||
smsCode: [
|
||||
{ required: true, message: "请输入验证码", trigger: "blur" },
|
||||
{ len: 6, message: "验证码为6位数字", trigger: "blur" },
|
||||
{ pattern: /^\d{6}$/, message: "验证码只能为数字", trigger: "blur" },
|
||||
],
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
@ -153,6 +215,7 @@ const handleSubmit = async () => {
|
||||
password: form.password,
|
||||
nickname: form.nickname,
|
||||
phone: form.phone,
|
||||
smsCode: form.smsCode,
|
||||
})
|
||||
message.success("注册成功")
|
||||
} else {
|
||||
@ -164,8 +227,14 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
// 保存 token 和用户信息
|
||||
if (result?.token && result?.user) {
|
||||
localStorage.setItem("public_token", result.token)
|
||||
localStorage.setItem("public_user", JSON.stringify(result.user))
|
||||
} else {
|
||||
console.error("注册/登录返回数据异常:", result)
|
||||
message.error(isRegister.value ? "注册返回数据异常" : "登录返回数据异常")
|
||||
return
|
||||
}
|
||||
|
||||
// 跳转
|
||||
const redirect = (route.query.redirect as string) || "/p/activities"
|
||||
|
||||
@ -50,7 +50,6 @@ const initToken = async () => {
|
||||
try {
|
||||
const data = await leaiApi.getToken()
|
||||
store.setSession(data.orgId, data.token)
|
||||
if (data.phone) store.setPhone(data.phone)
|
||||
} catch (err: any) {
|
||||
const errMsg = err?.response?.data?.message || err?.message || '加载失败'
|
||||
loadError.value = errMsg
|
||||
@ -63,8 +62,8 @@ const initToken = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 如果 store 中已有有效 token,跳过加载
|
||||
if (store.sessionToken) {
|
||||
// 如果 store 中已有有效 token 且 orgId 已初始化,跳过加载
|
||||
if (store.sessionToken && store.orgId) {
|
||||
loading.value = false
|
||||
} else {
|
||||
initToken()
|
||||
|
||||
@ -172,10 +172,10 @@ onMounted(async () => {
|
||||
try {
|
||||
let work
|
||||
const shareToken = new URLSearchParams(window.location.search).get('st') || ''
|
||||
if (store.sessionToken || store.appSecret) {
|
||||
// 认证用户: 用 axios 实例(自动加 HMAC/Bearer 头)
|
||||
if (store.sessionToken) {
|
||||
// 认证用户: 走后端代理
|
||||
const res = await getWorkDetail(workId)
|
||||
work = res.data
|
||||
work = res
|
||||
} else if (shareToken) {
|
||||
// 分享链接: 无认证,用 shareToken
|
||||
const leaiBase = import.meta.env.VITE_LEAI_API_URL || ''
|
||||
|
||||
@ -111,7 +111,7 @@ onMounted(async () => {
|
||||
}
|
||||
try {
|
||||
const res = await extractCharacters(store.imageUrl)
|
||||
const data = res.data || {}
|
||||
const data = res || {}
|
||||
characters.value = (data.characters || []).map((c: any) => ({
|
||||
...c,
|
||||
type: c.charType || c.type || 'SIDEKICK'
|
||||
|
||||
@ -276,7 +276,7 @@ const startCreation = async () => {
|
||||
extractId: store.extractId,
|
||||
})
|
||||
|
||||
const workId = res.data?.workId
|
||||
const workId = res?.workId
|
||||
if (!workId) {
|
||||
error.value = res.msg || '创作提交失败'
|
||||
submitted = false
|
||||
|
||||
@ -372,7 +372,7 @@ async function voiceSingle() {
|
||||
voicingSingle.value = true
|
||||
try {
|
||||
const res = await voicePage({ workId: workId.value, voiceAll: false, pageNum: currentPage.value.pageNum })
|
||||
const data = res.data
|
||||
const data = res
|
||||
if (data.voicedPages?.length) {
|
||||
for (const vp of data.voicedPages) {
|
||||
const p = pages.value.find(x => x.pageNum === vp.pageNum)
|
||||
@ -403,7 +403,7 @@ async function voiceAllConfirm() {
|
||||
voicingAll.value = true
|
||||
try {
|
||||
const res = await voicePage({ workId: workId.value, voiceAll: true })
|
||||
const data = res.data
|
||||
const data = res
|
||||
if (data.voicedPages) {
|
||||
for (const vp of data.voicedPages) {
|
||||
const p = pages.value.find(x => x.pageNum === vp.pageNum)
|
||||
@ -459,7 +459,7 @@ async function finish() {
|
||||
// 容错:即使报错也检查实际状态,可能请求已经成功但重试触发了CAS失败
|
||||
try {
|
||||
const check = await getWorkDetail(workId.value)
|
||||
if (check?.data?.status >= 5) {
|
||||
if (check?.status >= 5) {
|
||||
store.workDetail = null
|
||||
showToast('配音已完成')
|
||||
setTimeout(() => router.push(`/p/create/read/${workId.value}`), 800)
|
||||
@ -480,7 +480,7 @@ async function loadWork() {
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
store.workDetail = res.data
|
||||
store.workDetail = res
|
||||
}
|
||||
const w = store.workDetail
|
||||
|
||||
|
||||
@ -144,7 +144,7 @@ async function loadWork() {
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
store.workDetail = res.data
|
||||
store.workDetail = res
|
||||
}
|
||||
const w = store.workDetail
|
||||
|
||||
|
||||
@ -114,7 +114,7 @@ async function loadWork() {
|
||||
error.value = ''
|
||||
try {
|
||||
const res = await getWorkDetail(workId.value)
|
||||
const work = res.data
|
||||
const work = res
|
||||
store.workDetail = work
|
||||
store.workId = work.workId
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ async function loadWork() {
|
||||
if (!store.workDetail || store.workDetail.workId !== workId.value) {
|
||||
store.workDetail = null
|
||||
const res = await getWorkDetail(workId.value)
|
||||
store.workDetail = res.data
|
||||
store.workDetail = res
|
||||
}
|
||||
const w = store.workDetail
|
||||
title.value = w.title || '我的绘本'
|
||||
|
||||
@ -275,7 +275,7 @@ const goNext = async () => {
|
||||
const res = await extractCharacters(ossUrl, {
|
||||
saveOriginal: saveOriginal.value
|
||||
})
|
||||
const data = res.data || {}
|
||||
const data = res || {}
|
||||
const chars = (data.characters || []).map((c: any) => ({
|
||||
...c,
|
||||
type: c.charType || c.type || 'SIDEKICK'
|
||||
|
||||
@ -66,22 +66,9 @@
|
||||
|
||||
<!-- 底部(固定) -->
|
||||
<div class="bottom-area safe-bottom">
|
||||
<!-- Token模式无token时 -->
|
||||
<template v-if="isTokenMode && !store.sessionToken">
|
||||
<div class="auth-prompt" style="text-align:center;padding:20px;">
|
||||
<p style="font-size:16px;color:#666;margin-bottom:8px;">本应用需要通过企业入口访问</p>
|
||||
<p style="font-size:14px;color:#999;margin-bottom:20px;">请联系您的管理员获取访问链接</p>
|
||||
<button v-if="store.authRedirectUrl" class="btn-primary start-btn" @click="goToEnterprise">
|
||||
<span class="btn-icon">🔑</span> 前往企业认证
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 有token或HMAC模式 -->
|
||||
<template v-else>
|
||||
<button class="btn-primary start-btn" @click="handleStart">
|
||||
<span class="btn-icon">🚀</span> 开始创作
|
||||
</button>
|
||||
</template>
|
||||
<div class="slogan">让每个孩子都是小画家 ✨</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -119,13 +106,8 @@ const handleStart = () => {
|
||||
router.push('/p/create/upload')
|
||||
}
|
||||
|
||||
const goToEnterprise = () => {
|
||||
window.location.href = store.authRedirectUrl
|
||||
}
|
||||
|
||||
const brandTitle = config.brand.title || '乐读派'
|
||||
const brandSubtitle = config.brand.subtitle || 'AI智能儿童绘本创作'
|
||||
const isTokenMode = true // 整合后总是 Token 模式
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@ -1,45 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"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"
|
||||
]
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,150 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,143 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,143 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,143 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,143 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,143 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
@ -1,185 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||