platform-management.page.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. import { Page, Locator, Response } from '@playwright/test';
  2. /**
  3. * 平台状态常量
  4. */
  5. export const PLATFORM_STATUS = {
  6. ENABLED: 0,
  7. DISABLED: 1,
  8. } as const;
  9. /**
  10. * 平台状态类型
  11. */
  12. export type PlatformStatus = typeof PLATFORM_STATUS[keyof typeof PLATFORM_STATUS];
  13. /**
  14. * 平台状态显示名称映射
  15. */
  16. export const PLATFORM_STATUS_LABELS: Record<PlatformStatus, string> = {
  17. 0: '启用',
  18. 1: '禁用',
  19. } as const;
  20. /**
  21. * 平台数据接口
  22. */
  23. export interface PlatformData {
  24. /** 平台名称 */
  25. platformName: string;
  26. /** 联系人 */
  27. contactPerson?: string;
  28. /** 联系电话 */
  29. contactPhone?: string;
  30. /** 联系邮箱 */
  31. contactEmail?: string;
  32. }
  33. /**
  34. * 网络响应数据接口
  35. */
  36. export interface NetworkResponse {
  37. /** 请求URL */
  38. url: string;
  39. /** 请求方法 */
  40. method: string;
  41. /** 响应状态码 */
  42. status: number;
  43. /** 是否成功 */
  44. ok: boolean;
  45. /** 响应头 */
  46. responseHeaders: Record<string, string>;
  47. /** 响应体 */
  48. responseBody: unknown;
  49. }
  50. /**
  51. * 表单提交结果接口
  52. */
  53. export interface FormSubmitResult {
  54. /** 提交是否成功 */
  55. success: boolean;
  56. /** 是否有错误 */
  57. hasError: boolean;
  58. /** 是否有成功消息 */
  59. hasSuccess: boolean;
  60. /** 错误消息 */
  61. errorMessage?: string;
  62. /** 成功消息 */
  63. successMessage?: string;
  64. /** 网络响应列表 */
  65. responses?: NetworkResponse[];
  66. }
  67. /**
  68. * 平台管理 Page Object
  69. *
  70. * 用于平台管理功能的 E2E 测试
  71. * 页面路径: /admin/platforms
  72. *
  73. * @example
  74. * ```typescript
  75. * const platformPage = new PlatformManagementPage(page);
  76. * await platformPage.goto();
  77. * await platformPage.createPlatform({ platformName: '测试平台' });
  78. * ```
  79. */
  80. export class PlatformManagementPage {
  81. readonly page: Page;
  82. // ===== 页面级选择器 =====
  83. /** 页面标题 */
  84. readonly pageTitle: Locator;
  85. /** 创建平台按钮 */
  86. readonly createPlatformButton: Locator;
  87. /** 搜索输入框 */
  88. readonly searchInput: Locator;
  89. /** 搜索按钮 */
  90. readonly searchButton: Locator;
  91. /** 平台列表表格 */
  92. readonly platformTable: Locator;
  93. // ===== 对话框选择器 =====
  94. /** 创建对话框标题 */
  95. readonly createDialogTitle: Locator;
  96. /** 编辑对话框标题 */
  97. readonly editDialogTitle: Locator;
  98. // ===== 表单字段选择器 =====
  99. /** 平台名称输入框 */
  100. readonly platformNameInput: Locator;
  101. /** 联系人输入框 */
  102. readonly contactPersonInput: Locator;
  103. /** 联系电话输入框 */
  104. readonly contactPhoneInput: Locator;
  105. /** 联系邮箱输入框 */
  106. readonly contactEmailInput: Locator;
  107. // ===== 按钮选择器 =====
  108. /** 创建提交按钮 */
  109. readonly createSubmitButton: Locator;
  110. /** 更新提交按钮 */
  111. readonly updateSubmitButton: Locator;
  112. /** 取消按钮 */
  113. readonly cancelButton: Locator;
  114. // ===== 删除确认对话框选择器 =====
  115. /** 确认删除按钮 */
  116. readonly confirmDeleteButton: Locator;
  117. constructor(page: Page) {
  118. this.page = page;
  119. // 初始化页面级选择器
  120. // 使用精确文本匹配获取页面标题
  121. this.pageTitle = page.getByText('平台管理', { exact: true });
  122. // 使用 data-testid 定位创建平台按钮
  123. this.createPlatformButton = page.getByTestId('create-platform-button');
  124. // 使用 data-testid 定位搜索相关元素
  125. this.searchInput = page.getByTestId('search-input');
  126. this.searchButton = page.getByTestId('search-button');
  127. // 平台列表表格
  128. this.platformTable = page.locator('table');
  129. // 对话框标题选择器
  130. this.createDialogTitle = page.getByTestId('create-platform-dialog-title');
  131. this.editDialogTitle = page.getByTestId('edit-platform-dialog-title');
  132. // 表单字段选择器 - 使用 data-testid
  133. this.platformNameInput = page.getByTestId('platform-name-input');
  134. this.contactPersonInput = page.getByTestId('contact-person-input');
  135. this.contactPhoneInput = page.getByTestId('contact-phone-input');
  136. this.contactEmailInput = page.getByTestId('contact-email-input');
  137. // 按钮选择器
  138. this.createSubmitButton = page.getByTestId('create-submit-button');
  139. this.updateSubmitButton = page.getByTestId('update-submit-button');
  140. this.cancelButton = page.getByRole('button', { name: '取消' });
  141. // 删除确认对话框按钮
  142. this.confirmDeleteButton = page.getByTestId('confirm-delete-button');
  143. }
  144. // ===== 导航和基础验证 =====
  145. /**
  146. * 导航到平台管理页面
  147. */
  148. async goto(): Promise<void> {
  149. await this.page.goto('/admin/platforms');
  150. await this.page.waitForLoadState('domcontentloaded');
  151. // 等待页面标题出现
  152. await this.pageTitle.waitFor({ state: 'visible', timeout: 15000 });
  153. // 等待表格数据加载
  154. await this.platformTable.waitFor({ state: 'visible', timeout: 20000 });
  155. await this.expectToBeVisible();
  156. }
  157. /**
  158. * 验证页面关键元素可见
  159. */
  160. async expectToBeVisible(): Promise<void> {
  161. await this.pageTitle.waitFor({ state: 'visible', timeout: 15000 });
  162. await this.createPlatformButton.waitFor({ state: 'visible', timeout: 10000 });
  163. }
  164. // ===== 对话框操作 =====
  165. /**
  166. * 打开创建平台对话框
  167. */
  168. async openCreateDialog(): Promise<void> {
  169. await this.createPlatformButton.click();
  170. // 等待对话框出现
  171. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
  172. }
  173. /**
  174. * 打开编辑平台对话框
  175. * @param platformName 平台名称
  176. */
  177. async openEditDialog(platformName: string): Promise<void> {
  178. // 找到平台行并点击编辑按钮
  179. const platformRow = this.platformTable.locator('tbody tr').filter({ hasText: platformName });
  180. // 使用 role + name 组合定位编辑按钮,更健壮
  181. const editButton = platformRow.getByRole('button', { name: '编辑' });
  182. await editButton.click();
  183. // 等待编辑对话框出现
  184. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
  185. }
  186. /**
  187. * 打开删除确认对话框
  188. * @param platformName 平台名称
  189. */
  190. async openDeleteDialog(platformName: string): Promise<void> {
  191. // 找到平台行并点击删除按钮
  192. const platformRow = this.platformTable.locator('tbody tr').filter({ hasText: platformName });
  193. // 使用 role + name 组合定位删除按钮,更健壮
  194. const deleteButton = platformRow.getByRole('button', { name: '删除' });
  195. await deleteButton.click();
  196. // 等待删除确认对话框出现
  197. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
  198. }
  199. /**
  200. * 填写平台表单
  201. * @param data 平台数据
  202. */
  203. async fillPlatformForm(data: PlatformData): Promise<void> {
  204. // 等待表单出现
  205. await this.page.waitForSelector('form', { state: 'visible', timeout: 5000 });
  206. // 填写平台名称(必填字段)
  207. if (data.platformName) {
  208. await this.platformNameInput.fill(data.platformName);
  209. }
  210. // 填写联系人(可选字段)
  211. if (data.contactPerson !== undefined) {
  212. await this.contactPersonInput.fill(data.contactPerson);
  213. }
  214. // 填写联系电话(可选字段)
  215. if (data.contactPhone !== undefined) {
  216. await this.contactPhoneInput.fill(data.contactPhone);
  217. }
  218. // 填写联系邮箱(可选字段)
  219. if (data.contactEmail !== undefined) {
  220. await this.contactEmailInput.fill(data.contactEmail);
  221. }
  222. }
  223. /**
  224. * 提交表单
  225. * @returns 表单提交结果
  226. */
  227. async submitForm(): Promise<FormSubmitResult> {
  228. // 收集网络响应
  229. const responses: NetworkResponse[] = [];
  230. // 监听所有网络请求
  231. const responseHandler = async (response: Response) => {
  232. const url = response.url();
  233. // 监听平台管理相关的 API 请求
  234. if (url.includes('/platforms') || url.includes('platform')) {
  235. const requestBody = response.request()?.postData();
  236. const responseBody = await response.text().catch(() => '');
  237. let jsonBody = null;
  238. try {
  239. jsonBody = JSON.parse(responseBody);
  240. } catch {
  241. // 不是 JSON 响应
  242. }
  243. responses.push({
  244. url,
  245. method: response.request()?.method() ?? 'UNKNOWN',
  246. status: response.status(),
  247. ok: response.ok(),
  248. responseHeaders: await response.allHeaders().catch(() => ({})),
  249. responseBody: jsonBody || responseBody,
  250. });
  251. }
  252. };
  253. this.page.on('response', responseHandler as unknown as () => void);
  254. try {
  255. // 点击提交按钮(创建或更新)
  256. const submitButton = this.page.getByRole('button', { name: /^(创建|更新|保存)$/ });
  257. await submitButton.click();
  258. // 等待网络请求完成
  259. try {
  260. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  261. } catch {
  262. // domcontentloaded 超时不是致命错误
  263. console.debug('domcontentloaded 超时,继续检查 Toast 消息');
  264. }
  265. } finally {
  266. // 确保监听器总是被移除
  267. this.page.off('response', responseHandler as unknown as () => void);
  268. }
  269. // 等待 Toast 消息显示
  270. await this.page.waitForTimeout(1500);
  271. // 检查 Toast 消息
  272. const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
  273. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  274. const hasError = await errorToast.count() > 0;
  275. const hasSuccess = await successToast.count() > 0;
  276. let errorMessage: string | null = null;
  277. let successMessage: string | null = null;
  278. if (hasError) {
  279. errorMessage = await errorToast.first().textContent();
  280. }
  281. if (hasSuccess) {
  282. successMessage = await successToast.first().textContent();
  283. }
  284. return {
  285. success: hasSuccess || (!hasError && !hasSuccess),
  286. hasError,
  287. hasSuccess,
  288. errorMessage: errorMessage ?? undefined,
  289. successMessage: successMessage ?? undefined,
  290. responses,
  291. };
  292. }
  293. /**
  294. * 取消对话框
  295. */
  296. async cancelDialog(): Promise<void> {
  297. await this.cancelButton.click();
  298. await this.waitForDialogClosed();
  299. }
  300. /**
  301. * 等待对话框关闭
  302. */
  303. async waitForDialogClosed(): Promise<void> {
  304. const dialog = this.page.locator('[role="dialog"]');
  305. await dialog.waitFor({ state: 'hidden', timeout: 5000 })
  306. .catch(() => console.debug('对话框关闭超时,可能已经关闭'));
  307. await this.page.waitForTimeout(500);
  308. }
  309. /**
  310. * 确认删除操作
  311. */
  312. async confirmDelete(): Promise<void> {
  313. await this.confirmDeleteButton.click();
  314. // 等待确认对话框关闭和网络请求完成
  315. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 })
  316. .catch(() => console.debug('删除确认对话框关闭超时'));
  317. try {
  318. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  319. } catch {
  320. // 继续执行
  321. }
  322. await this.page.waitForTimeout(1000);
  323. }
  324. /**
  325. * 取消删除操作
  326. */
  327. async cancelDelete(): Promise<void> {
  328. const cancelButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '取消' });
  329. await cancelButton.click();
  330. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 })
  331. .catch(() => console.debug('删除确认对话框关闭超时(取消操作)'));
  332. }
  333. // ===== CRUD 操作方法 =====
  334. /**
  335. * 创建平台(完整流程)
  336. * @param data 平台数据
  337. * @returns 表单提交结果
  338. */
  339. async createPlatform(data: PlatformData): Promise<FormSubmitResult> {
  340. await this.openCreateDialog();
  341. await this.fillPlatformForm(data);
  342. const result = await this.submitForm();
  343. await this.waitForDialogClosed();
  344. return result;
  345. }
  346. /**
  347. * 编辑平台(完整流程)
  348. * @param platformName 平台名称
  349. * @param data 更新的平台数据
  350. * @returns 表单提交结果
  351. */
  352. async editPlatform(platformName: string, data: PlatformData): Promise<FormSubmitResult> {
  353. await this.openEditDialog(platformName);
  354. await this.fillPlatformForm(data);
  355. const result = await this.submitForm();
  356. await this.waitForDialogClosed();
  357. return result;
  358. }
  359. /**
  360. * 删除平台(完整流程)
  361. * @param platformName 平台名称
  362. * @returns 是否成功删除
  363. */
  364. async deletePlatform(platformName: string): Promise<boolean> {
  365. try {
  366. await this.openDeleteDialog(platformName);
  367. await this.confirmDelete();
  368. // 等待并检查 Toast 消息
  369. await this.page.waitForTimeout(1000);
  370. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  371. const hasSuccess = await successToast.count() > 0;
  372. return hasSuccess;
  373. } catch (error) {
  374. console.debug(`删除平台 "${platformName}" 失败:`, error);
  375. return false;
  376. }
  377. }
  378. // ===== 搜索和验证方法 =====
  379. /**
  380. * 按平台名称搜索
  381. * @param name 平台名称
  382. * @returns 搜索结果是否包含目标平台
  383. */
  384. async searchByName(name: string): Promise<boolean> {
  385. await this.searchInput.fill(name);
  386. await this.searchButton.click();
  387. await this.page.waitForLoadState('domcontentloaded');
  388. await this.page.waitForTimeout(1000);
  389. // 验证搜索结果
  390. return await this.platformExists(name);
  391. }
  392. /**
  393. * 验证平台是否存在(使用精确匹配)
  394. * @param platformName 平台名称
  395. * @returns 平台是否存在
  396. */
  397. async platformExists(platformName: string): Promise<boolean> {
  398. const platformRow = this.platformTable.locator('tbody tr').filter({ hasText: platformName });
  399. const count = await platformRow.count();
  400. if (count === 0) return false;
  401. // 进一步验证第一列的文本是否完全匹配平台名称
  402. // 避免部分匹配导致的误判(如搜索"测试"匹配"测试平台A")
  403. const firstCell = platformRow.locator('td').first();
  404. const actualText = await firstCell.textContent();
  405. return actualText?.trim() === platformName;
  406. }
  407. }