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>
This commit is contained in:
En 2026-04-09 21:31:25 +08:00
parent d7dddd3058
commit f1d40db322
127 changed files with 989 additions and 7582 deletions

View File

@ -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>

View File

@ -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;
}

View File

@ -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, "活动已发布"),

View File

@ -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();
}
}

View File

@ -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";
}

View File

@ -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);

View File

@ -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));
}
}

View File

@ -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;
}

View File

@ -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());
}
}
}

View File

@ -88,8 +88,14 @@ public class LeaiSyncService implements ILeaiSyncService {
return;
}
// 旧数据或重复推送忽略
log.debug("[{}] 跳过 remoteWorkId={}, remote={} <= local={}", source, remoteWorkId, remoteStatus, localStatus);
// 旧数据或重复推送忽略状态更新
// 但如果 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 查找本地作品
*/

View File

@ -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对账] 开始执行...");

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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));

View File

@ -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);
// 存储验证码到 Redis5 分钟有效
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());
}
}

View File

@ -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);
}
}

View File

@ -34,7 +34,7 @@ public class SysUser extends BaseEntity {
@Schema(description = "邮箱")
private String email;
@Schema(description = "手机号(全局唯一)")
@Schema(description = "手机号(租户内唯一)")
private String phone;
@Schema(description = "微信OpenID")

View File

@ -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秒

View File

@ -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:
@ -53,4 +61,11 @@ aliyun:
max-file-size: ${OSS_MAX_FILE_SIZE:10485760}
# 前端直传跨域:启动时自动配置 OSS CORS
cors-enabled: ${OSS_CORS_ENABLED:true}
cors-allowed-origins: ${OSS_CORS_ORIGINS:*}
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

View File

@ -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秒

View File

@ -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);

View File

@ -3,9 +3,8 @@ import { test, expect } from '../fixtures/auth.fixture'
/**
* P0: 认证 API
*
* LeaiAuthController
* LeaiAuthController
* - GET /leai-auth/tokeniframe
* - GET /leai-auth302
* - GET /leai-auth/refresh-tokenToken
*/
@ -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-auth302 重定向)', () => {
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')
})
})
})

View File

@ -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',
},
}),

View File

@ -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' },
}),
})
})

View File

@ -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',
},
}),

View File

@ -49,7 +49,6 @@ test.describe('postMessage 通信', () => {
data: {
token: 'mock_token_for_postmessage_test',
orgId: 'gdlib',
h5Url: 'http://localhost:3001',
phone: '13800001111',
},
}),

View File

@ -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',
}

View File

@ -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

View File

@ -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"),
// 刷新 TokenTOKEN_EXPIRED 时调用)
refreshToken: (): Promise<{
token: string
orgId: string
phone: string
}> => publicApi.get("/leai-auth/refresh-token"),
}

View File

@ -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)

View File

@ -1,17 +1,15 @@
/**
* AI Pinia Store
*
* lesingle-aicreate-client/utils/store.js
* Pinia setup
* phone/orgId/appSecret localStorage
* orgId sessionStoragesessionToken
*/
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,

View File

@ -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
}
}

View File

@ -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
localStorage.setItem("public_token", result.token)
localStorage.setItem("public_user", JSON.stringify(result.user))
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"

View File

@ -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()

View File

@ -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 || ''

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 || '我的绘本'

View File

@ -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'

View File

@ -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>
<button class="btn-primary start-btn" @click="handleStart">
<span class="btn-icon">🚀</span> 开始创作
</button>
<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>

View File

@ -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": []
}

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

View File

@ -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 |
```

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