user-management.page.ts 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882
  1. import { TIMEOUTS } from '../../utils/timeouts';
  2. import { Page, Locator } from '@playwright/test';
  3. import { selectRadixOptionAsync } from '@d8d/e2e-test-utils';
  4. import { UserType, DisabledStatus } from '@d8d/shared-types';
  5. /**
  6. * API 基础 URL
  7. */
  8. const API_BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:8080';
  9. /**
  10. * 用户状态常量
  11. */
  12. export const USER_STATUS = {
  13. ENABLED: 0,
  14. DISABLED: 1,
  15. } as const;
  16. /**
  17. * 用户状态类型
  18. */
  19. export type UserStatus = typeof USER_STATUS[keyof typeof USER_STATUS];
  20. /**
  21. * 用户状态显示名称映射
  22. */
  23. export const USER_STATUS_LABELS: Record<UserStatus, string> = {
  24. 0: '启用',
  25. 1: '禁用',
  26. } as const;
  27. /**
  28. * 用户数据接口(创建用户)
  29. */
  30. export interface UserData {
  31. /** 用户名(必填) */
  32. username: string;
  33. /** 密码(必填) */
  34. password: string;
  35. /** 昵称(可选) */
  36. nickname?: string | null;
  37. /** 邮箱(可选) */
  38. email?: string | null;
  39. /** 手机号(可选) */
  40. phone?: string | null;
  41. /** 真实姓名(可选) */
  42. name?: string | null;
  43. /** 用户类型(默认:管理员) */
  44. userType?: UserType;
  45. /** 关联公司ID(企业用户必填) */
  46. companyId?: number | null;
  47. /** 关联残疾人ID(人才用户必填) */
  48. personId?: number | null;
  49. /** 是否禁用(默认:启用) */
  50. isDisabled?: DisabledStatus;
  51. }
  52. /**
  53. * 用户更新数据接口(编辑用户)
  54. */
  55. export interface UserUpdateData {
  56. /** 用户名 */
  57. username?: string;
  58. /** 昵称 */
  59. nickname?: string | null;
  60. /** 邮箱 */
  61. email?: string | null;
  62. /** 手机号 */
  63. phone?: string | null;
  64. /** 真实姓名 */
  65. name?: string | null;
  66. /** 新密码(可选) */
  67. password?: string;
  68. /** 用户类型 */
  69. userType?: UserType;
  70. /** 关联公司ID(企业用户必填) */
  71. companyId?: number | null;
  72. /** 关联残疾人ID(人才用户必填) */
  73. personId?: number | null;
  74. /** 是否禁用 */
  75. isDisabled?: DisabledStatus;
  76. }
  77. /**
  78. * 网络响应数据接口
  79. */
  80. export interface NetworkResponse {
  81. /** 请求URL */
  82. url: string;
  83. /** 请求方法 */
  84. method: string;
  85. /** 响应状态码 */
  86. status: number;
  87. /** 是否成功 */
  88. ok: boolean;
  89. /** 响应头 */
  90. responseHeaders: Record<string, string>;
  91. /** 响应体 */
  92. responseBody: unknown;
  93. }
  94. /**
  95. * 表单提交结果接口
  96. */
  97. export interface FormSubmitResult {
  98. /** 提交是否成功 */
  99. success: boolean;
  100. /** 是否有错误 */
  101. hasError: boolean;
  102. /** 是否有成功消息 */
  103. hasSuccess: boolean;
  104. /** 错误消息 */
  105. errorMessage?: string;
  106. /** 成功消息 */
  107. successMessage?: string;
  108. /** 网络响应列表 */
  109. responses?: NetworkResponse[];
  110. }
  111. /**
  112. * 用户管理 Page Object
  113. *
  114. * 用于用户管理功能的 E2E 测试
  115. * 页面路径: /admin/users
  116. *
  117. * 支持三种用户类型:
  118. * - ADMIN:管理员,无关联
  119. * - EMPLOYER:企业用户,需关联公司
  120. * - TALENT:人才用户,需关联残疾人
  121. *
  122. * @example
  123. * ```typescript
  124. * const userPage = new UserManagementPage(page);
  125. * await userPage.goto();
  126. * await userPage.createUser({ username: 'testuser', password: 'password123' });
  127. * ```
  128. */
  129. export class UserManagementPage {
  130. readonly page: Page;
  131. // ===== API 端点常量 =====
  132. /** RESTful API 基础端点 */
  133. private static readonly API_USERS_BASE = `${API_BASE_URL}/api/v1/users`;
  134. /** 获取所有用户列表 API */
  135. private static readonly API_GET_ALL_USERS = `${API_BASE_URL}/api/v1/users`;
  136. /** 删除用户 API(需要拼接 /${userId}) */
  137. private static readonly API_DELETE_USER = `${API_BASE_URL}/api/v1/users`;
  138. // ===== 页面级选择器 =====
  139. /** 页面标题 */
  140. readonly pageTitle: Locator;
  141. /** 创建用户按钮 */
  142. readonly createUserButton: Locator;
  143. /** 搜索输入框 */
  144. readonly searchInput: Locator;
  145. /** 搜索按钮 */
  146. readonly searchButton: Locator;
  147. /** 用户列表表格 */
  148. readonly userTable: Locator;
  149. // ===== 对话框选择器 =====
  150. /** 创建对话框标题 */
  151. readonly createDialogTitle: Locator;
  152. /** 编辑对话框标题 */
  153. readonly editDialogTitle: Locator;
  154. // ===== 表单字段选择器 =====
  155. /** 用户名输入框 */
  156. readonly usernameInput: Locator;
  157. /** 密码输入框 */
  158. readonly passwordInput: Locator;
  159. /** 昵称输入框 */
  160. readonly nicknameInput: Locator;
  161. /** 邮箱输入框 */
  162. readonly emailInput: Locator;
  163. /** 手机号输入框 */
  164. readonly phoneInput: Locator;
  165. /** 真实姓名输入框 */
  166. readonly nameInput: Locator;
  167. /** 用户类型选择器 */
  168. readonly userTypeSelector: Locator;
  169. /** 企业选择器(用于 EMPLOYER 类型) */
  170. readonly companySelector: Locator;
  171. /** 残疾人选择器(用于 TALENT 类型) */
  172. readonly disabledPersonSelector: Locator;
  173. // ===== 按钮选择器 =====
  174. /** 创建提交按钮 */
  175. readonly createSubmitButton: Locator;
  176. /** 更新提交按钮 */
  177. readonly updateSubmitButton: Locator;
  178. /** 取消按钮 */
  179. readonly cancelButton: Locator;
  180. // ===== 删除确认对话框选择器 =====
  181. /** 确认删除按钮 */
  182. readonly confirmDeleteButton: Locator;
  183. constructor(page: Page) {
  184. this.page = page;
  185. // 初始化页面级选择器
  186. // 使用 heading role 精确定位页面标题(避免与侧边栏按钮冲突)
  187. this.pageTitle = page.getByRole('heading', { name: '用户管理' });
  188. // 使用 data-testid 定位创建用户按钮
  189. this.createUserButton = page.getByTestId('create-user-button');
  190. // 搜索相关元素
  191. this.searchInput = page.getByTestId('search-user-input');
  192. this.searchButton = page.getByTestId('search-user-button');
  193. // 用户列表表格
  194. this.userTable = page.locator('table');
  195. // 对话框标题选择器
  196. this.createDialogTitle = page.getByTestId('create-user-dialog-title');
  197. this.editDialogTitle = page.getByTestId('edit-user-dialog-title');
  198. // 表单字段选择器 - 使用 label 定位
  199. this.usernameInput = page.getByLabel('用户名');
  200. this.passwordInput = page.getByLabel('密码');
  201. this.nicknameInput = page.getByLabel('昵称');
  202. this.emailInput = page.getByLabel('邮箱');
  203. this.phoneInput = page.getByLabel('手机号');
  204. this.nameInput = page.getByLabel('真实姓名');
  205. // 用户类型选择器(创建表单)
  206. this.userTypeSelector = page.getByTestId('用户类型-trigger');
  207. // 用户类型选择器(编辑表单)
  208. this.userTypeSelectorEdit = page.getByTestId('用户类型-edit-trigger');
  209. // 企业选择器(用于 EMPLOYER 类型)
  210. this.companySelector = page.getByTestId('关联企业-trigger');
  211. // 残疾人选择器(用于 TALENT 类型)
  212. this.disabledPersonSelector = page.getByTestId('disabled-person-selector');
  213. this.disabledPersonSelectorEdit = page.getByTestId('关联残疾人-edit-trigger');
  214. // 按钮选择器
  215. this.createSubmitButton = page.getByTestId('create-user-submit-button');
  216. this.updateSubmitButton = page.getByTestId('update-user-submit-button');
  217. this.cancelButton = page.getByRole('button', { name: '取消' });
  218. // 删除确认对话框按钮
  219. this.confirmDeleteButton = page.getByTestId('confirm-delete-user-button');
  220. }
  221. // ===== 导航和基础验证 =====
  222. /**
  223. * 导航到用户管理页面
  224. */
  225. async goto(): Promise<void> {
  226. await this.page.goto('/admin/users');
  227. await this.page.waitForLoadState('domcontentloaded');
  228. // 等待页面标题出现
  229. await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  230. // 等待表格数据加载
  231. await this.userTable.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD_LONG });
  232. await this.expectToBeVisible();
  233. }
  234. /**
  235. * 验证页面关键元素可见
  236. */
  237. async expectToBeVisible(): Promise<void> {
  238. await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  239. await this.createUserButton.waitFor({ state: 'visible', timeout: TIMEOUTS.TABLE_LOAD });
  240. }
  241. // ===== 对话框操作 =====
  242. /**
  243. * 打开创建用户对话框
  244. */
  245. async openCreateDialog(): Promise<void> {
  246. await this.createUserButton.click();
  247. // 等待对话框出现
  248. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  249. }
  250. /**
  251. * 打开编辑用户对话框
  252. * @param username 用户名
  253. */
  254. async openEditDialog(username: string): Promise<void> {
  255. // 找到用户行并点击编辑按钮
  256. const userRow = this.userTable.locator('tbody tr').filter({ hasText: username });
  257. // 使用 role + name 组合定位编辑按钮,更健壮
  258. const editButton = userRow.getByRole('button', { name: '编辑用户' });
  259. await editButton.click();
  260. // 等待编辑对话框出现
  261. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  262. }
  263. /**
  264. * 打开删除确认对话框
  265. * @param username 用户名
  266. */
  267. async openDeleteDialog(username: string): Promise<void> {
  268. // 找到用户行并点击删除按钮
  269. const userRow = this.userTable.locator('tbody tr').filter({ hasText: username });
  270. // 使用 role + name 组合定位删除按钮,更健壮
  271. const deleteButton = userRow.getByRole('button', { name: '删除用户' });
  272. await deleteButton.click();
  273. // 等待删除确认对话框出现
  274. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  275. }
  276. /**
  277. * 填写用户表单
  278. * @param data 用户数据
  279. * @param companyName 公司名称(当用户类型为 EMPLOYER 时必须提供)
  280. * @param personName 残疾人姓名(当用户类型为 TALENT 时必须提供)
  281. */
  282. async fillUserForm(data: UserData, companyName?: string, personName?: string): Promise<void> {
  283. // 等待表单出现
  284. await this.page.waitForSelector('form', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  285. // 填写用户名(必填字段)
  286. if (data.username) {
  287. await this.usernameInput.fill(data.username);
  288. }
  289. // 填写密码(必填字段)
  290. if (data.password) {
  291. await this.passwordInput.fill(data.password);
  292. }
  293. // 填写昵称(可选字段)
  294. if (data.nickname !== undefined) {
  295. await this.nicknameInput.fill(data.nickname);
  296. }
  297. // 填写邮箱(可选字段)
  298. if (data.email !== undefined) {
  299. await this.emailInput.fill(data.email);
  300. }
  301. // 填写手机号(可选字段)
  302. if (data.phone !== undefined) {
  303. await this.phoneInput.fill(data.phone);
  304. }
  305. // 填写真实姓名(可选字段)
  306. if (data.name !== undefined) {
  307. await this.nameInput.fill(data.name);
  308. }
  309. // 选择用户类型(如果提供了且不是默认的 ADMIN)
  310. const userType = data.userType || UserType.ADMIN;
  311. if (userType !== UserType.ADMIN) {
  312. // 用户类型选择器使用标准 Radix UI Select 组件
  313. // 通过 data-testid 定位并点击
  314. // 等待用户类型选择器在 DOM 中存在
  315. await this.page.waitForSelector('[data-testid="用户类型-trigger"]', { state: 'attached', timeout: TIMEOUTS.DIALOG });
  316. // 滚动到用户类型选择器可见
  317. await this.userTypeSelector.scrollIntoViewIfNeeded();
  318. // 等待用户类型选择器可见
  319. await this.userTypeSelector.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  320. await this.userTypeSelector.click();
  321. // 等待选项出现
  322. await this.page.waitForSelector('[role="option"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  323. // 选择对应的用户类型
  324. const userTypeLabel = userType === UserType.EMPLOYER ? '企业用户' : '人才用户';
  325. await this.page.getByRole('option', { name: userTypeLabel }).click();
  326. }
  327. // 填写企业选择器(当用户类型为 EMPLOYER 时)
  328. if (userType === UserType.EMPLOYER && data.companyId && companyName) {
  329. // 等待企业选择器可见(通过 data-testid 定位)
  330. await this.page.waitForSelector('[data-testid="关联企业-trigger"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  331. // 点击企业选择器触发器
  332. await this.companySelector.click();
  333. // 等待选项出现
  334. await this.page.waitForSelector('[role="option"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  335. // 选择对应的公司
  336. await this.page.getByRole('option', { name: companyName }).click();
  337. }
  338. // 填写残疾人选择器(当用户类型为 TALENT 时)
  339. if (userType === UserType.TALENT && data.personId && personName) {
  340. // 使用 @d8d/e2e-test-utils 的 selectRadixOptionAsync 选择残疾人
  341. await selectRadixOptionAsync(this.page, '关联残疾人', personName);
  342. }
  343. }
  344. /**
  345. * 填写编辑用户表单
  346. * @param data 用户更新数据
  347. * @param companyName 公司名称(当用户类型为 EMPLOYER 时必须提供)
  348. * @param personName 残疾人姓名(当用户类型为 TALENT 时必须提供)
  349. */
  350. async fillEditUserForm(data: UserUpdateData, companyName?: string, personName?: string): Promise<void> {
  351. // 等待表单出现
  352. await this.page.waitForSelector('form', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  353. // 填写用户名
  354. if (data.username !== undefined) {
  355. await this.usernameInput.fill(data.username);
  356. }
  357. // 填写新密码(可选)
  358. if (data.password !== undefined) {
  359. await this.passwordInput.fill(data.password);
  360. }
  361. // 填写昵称
  362. if (data.nickname !== undefined) {
  363. await this.nicknameInput.fill(data.nickname);
  364. }
  365. // 填写邮箱
  366. if (data.email !== undefined) {
  367. await this.emailInput.fill(data.email);
  368. }
  369. // 填写手机号
  370. if (data.phone !== undefined) {
  371. await this.phoneInput.fill(data.phone);
  372. }
  373. // 填写真实姓名
  374. if (data.name !== undefined) {
  375. await this.nameInput.fill(data.name);
  376. }
  377. // 选择用户类型(如果提供了且不是默认的 ADMIN)
  378. const userType = data.userType || UserType.ADMIN;
  379. if (userType !== UserType.ADMIN) {
  380. // 用户类型选择器使用标准 Radix UI Select 组件
  381. // 通过 data-testid 定位并点击
  382. // 等待用户类型选择器在 DOM 中存在
  383. await this.page.waitForSelector('[data-testid="用户类型-trigger"]', { state: 'attached', timeout: TIMEOUTS.DIALOG });
  384. // 滚动到用户类型选择器可见
  385. await this.userTypeSelector.scrollIntoViewIfNeeded();
  386. // 等待用户类型选择器可见
  387. await this.userTypeSelector.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  388. await this.userTypeSelector.click();
  389. // 等待选项出现
  390. await this.page.waitForSelector('[role="option"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  391. // 选择对应的用户类型
  392. const userTypeLabel = userType === UserType.EMPLOYER ? '企业用户' : '人才用户';
  393. await this.page.getByRole('option', { name: userTypeLabel }).click();
  394. }
  395. // 填写企业选择器(当用户类型为 EMPLOYER 时)
  396. if (userType === UserType.EMPLOYER && data.companyId && companyName) {
  397. // 等待企业选择器可见(通过 data-testid 定位)
  398. await this.page.waitForSelector('[data-testid="关联企业-trigger"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  399. // 点击企业选择器触发器
  400. await this.companySelector.click();
  401. // 等待选项出现
  402. await this.page.waitForSelector('[role="option"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  403. // 选择对应的公司
  404. await this.page.getByRole('option', { name: companyName }).click();
  405. }
  406. // 填写残疾人选择器(当用户类型为 TALENT 时)
  407. if (userType === UserType.TALENT && data.personId && personName) {
  408. // 使用 @d8d/e2e-test-utils 的 selectRadixOptionAsync 选择残疾人
  409. await selectRadixOptionAsync(this.page, '关联残疾人', personName);
  410. }
  411. }
  412. /**
  413. * 提交表单
  414. * @returns 表单提交结果
  415. */
  416. async submitForm(): Promise<FormSubmitResult> {
  417. // 收集网络响应
  418. const responses: NetworkResponse[] = [];
  419. // 使用 waitForResponse 捕获特定 API 响应,避免并发测试中的监听器干扰
  420. const createUserPromise = this.page.waitForResponse(
  421. response => response.url().includes('/api/v1/users') && response.request().method() === 'POST',
  422. { timeout: TIMEOUTS.TABLE_LOAD }
  423. ).catch(() => null);
  424. const updateUserPromise = this.page.waitForResponse(
  425. response => response.url().includes('/api/v1/users') && response.request().method() === 'PUT',
  426. { timeout: TIMEOUTS.TABLE_LOAD }
  427. ).catch(() => null);
  428. // 捕获列表刷新响应(表单提交后会刷新用户列表)
  429. const getAllUsersPromise = this.page.waitForResponse(
  430. response => response.url().includes('/api/v1/users') && response.request().method() === 'GET',
  431. { timeout: TIMEOUTS.TABLE_LOAD }
  432. ).catch(() => null);
  433. try {
  434. // 点击提交按钮(优先使用 data-testid 选择器)
  435. let submitButton = this.page.locator('[data-testid="create-user-submit-button"]');
  436. if (await submitButton.count() === 0) {
  437. submitButton = this.page.getByRole('button', { name: /^(创建|更新)用户$/ });
  438. }
  439. await submitButton.click();
  440. // 等待 API 响应并收集
  441. const [createResponse, updateResponse] = await Promise.all([
  442. createUserPromise,
  443. updateUserPromise,
  444. ]);
  445. // getAllUsersPromise 单独处理,不需要等待其结果
  446. void getAllUsersPromise;
  447. // 处理捕获到的响应(创建或更新)
  448. const mainResponse = createResponse || updateResponse;
  449. if (mainResponse) {
  450. const responseBody = await mainResponse.text().catch(() => '');
  451. let jsonBody = null;
  452. try {
  453. jsonBody = JSON.parse(responseBody);
  454. } catch {
  455. // JSON 解析失败时,使用原始文本作为 response body
  456. }
  457. responses.push({
  458. url: mainResponse.url(),
  459. method: mainResponse.request()?.method() ?? 'UNKNOWN',
  460. status: mainResponse.status(),
  461. ok: mainResponse.ok(),
  462. responseHeaders: await mainResponse.allHeaders().catch(() => ({})),
  463. responseBody: jsonBody || responseBody,
  464. });
  465. }
  466. // 等待网络请求完成
  467. try {
  468. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.DIALOG });
  469. } catch {
  470. console.debug('submitForm: networkidle 超时,继续检查 Toast 消息');
  471. }
  472. } catch (error) {
  473. console.debug('submitForm 异常:', error);
  474. }
  475. // 主动等待 Toast 消息显示(最多等待 5 秒)
  476. const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
  477. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  478. // 等待任一 Toast 出现
  479. await Promise.race([
  480. errorToast.waitFor({ state: 'attached', timeout: TIMEOUTS.DIALOG }).catch(() => false),
  481. successToast.waitFor({ state: 'attached', timeout: TIMEOUTS.DIALOG }).catch(() => false),
  482. new Promise(resolve => setTimeout(() => resolve(false), 5000))
  483. ]);
  484. // 再次检查 Toast 是否存在
  485. let hasError = (await errorToast.count()) > 0;
  486. let hasSuccess = (await successToast.count()) > 0;
  487. // 如果标准选择器找不到,尝试更宽松的选择器
  488. if (!hasError && !hasSuccess) {
  489. // 尝试通过文本内容查找
  490. const allToasts = this.page.locator('[data-sonner-toast]');
  491. const count = await allToasts.count();
  492. for (let i = 0; i < count; i++) {
  493. const text = await allToasts.nth(i).textContent() || '';
  494. if (text.includes('成功') || text.toLowerCase().includes('success')) {
  495. hasSuccess = true;
  496. break;
  497. } else if (text.includes('失败') || text.includes('错误') || text.toLowerCase().includes('error')) {
  498. hasError = true;
  499. break;
  500. }
  501. }
  502. }
  503. let errorMessage: string | null = null;
  504. let successMessage: string | null = null;
  505. if (hasError) {
  506. errorMessage = await errorToast.textContent();
  507. }
  508. if (hasSuccess) {
  509. successMessage = await successToast.textContent();
  510. }
  511. console.debug('submitForm 结果:', {
  512. hasError,
  513. hasSuccess,
  514. errorMessage,
  515. successMessage,
  516. responsesCount: responses.length
  517. });
  518. return {
  519. success: hasSuccess || (!hasError && !hasSuccess && responses.some(r => r.ok)),
  520. hasError,
  521. hasSuccess,
  522. errorMessage: errorMessage ?? undefined,
  523. successMessage: successMessage ?? undefined,
  524. responses,
  525. };
  526. }
  527. /**
  528. * 取消对话框
  529. */
  530. async cancelDialog(): Promise<void> {
  531. await this.cancelButton.click();
  532. await this.waitForDialogClosed();
  533. }
  534. /**
  535. * 等待对话框关闭
  536. */
  537. async waitForDialogClosed(): Promise<void> {
  538. // 首先检查对话框是否已经关闭
  539. const dialog = this.page.locator('[role="dialog"]');
  540. const count = await dialog.count();
  541. if (count === 0) {
  542. console.debug('waitForDialogClosed: 对话框不存在,认为已关闭');
  543. return;
  544. }
  545. // 等待对话框隐藏
  546. await dialog.waitFor({ state: 'hidden', timeout: TIMEOUTS.DIALOG })
  547. .catch(() => {
  548. console.debug('waitForDialogClosed: 对话框关闭超时,可能已经关闭');
  549. });
  550. // 额外等待以确保 DOM 更新完成
  551. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  552. }
  553. /**
  554. * 确认删除操作
  555. */
  556. async confirmDelete(): Promise<void> {
  557. await this.confirmDeleteButton.click();
  558. // 等待确认对话框关闭和网络请求完成
  559. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  560. .catch(() => {
  561. console.debug('confirmDelete: 确认对话框关闭超时,继续执行');
  562. });
  563. try {
  564. await this.page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.DIALOG });
  565. } catch {
  566. console.debug('confirmDelete: 等待 DOM 加载超时,继续执行');
  567. }
  568. await this.page.waitForTimeout(TIMEOUTS.LONG);
  569. }
  570. /**
  571. * 取消删除操作
  572. */
  573. async cancelDelete(): Promise<void> {
  574. const cancelButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '取消' });
  575. await cancelButton.click();
  576. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  577. .catch(() => {
  578. // 继续执行
  579. });
  580. }
  581. // ===== CRUD 操作方法 =====
  582. /**
  583. * 创建用户(完整流程)
  584. * @param data 用户数据
  585. * @param companyName 公司名称(当用户类型为 EMPLOYER 时必须提供)
  586. * @param personName 残疾人姓名(当用户类型为 TALENT 时必须提供)
  587. * @returns 表单提交结果
  588. */
  589. async createUser(data: UserData, companyName?: string, personName?: string): Promise<FormSubmitResult> {
  590. await this.openCreateDialog();
  591. await this.fillUserForm(data, companyName, personName);
  592. const result = await this.submitForm();
  593. await this.waitForDialogClosed();
  594. return result;
  595. }
  596. /**
  597. * 编辑用户(完整流程)
  598. * @param username 用户名
  599. * @param data 更新的用户数据
  600. * @param companyName 公司名称(当用户类型为 EMPLOYER 时必须提供)
  601. * @param personName 残疾人姓名(当用户类型为 TALENT 时必须提供)
  602. * @returns 表单提交结果
  603. */
  604. async editUser(username: string, data: UserUpdateData, companyName?: string, personName?: string): Promise<FormSubmitResult> {
  605. await this.openEditDialog(username);
  606. await this.fillEditUserForm(data, companyName, personName);
  607. const result = await this.submitForm();
  608. await this.waitForDialogClosed();
  609. return result;
  610. }
  611. /**
  612. * 删除用户(使用 API 直接删除,绕过 UI)
  613. * @param username 用户名
  614. * @returns 是否成功删除
  615. */
  616. async deleteUser(username: string): Promise<boolean> {
  617. try {
  618. // 使用 API 直接删除,添加超时保护
  619. const result = await Promise.race([
  620. this.page.evaluate(async ({ username, apiGetAll, apiDelete }) => {
  621. // 尝试获取 token(使用标准键名)
  622. const token = localStorage.getItem('token') ||
  623. localStorage.getItem('auth_token') ||
  624. localStorage.getItem('accessToken');
  625. if (!token) {
  626. return { success: false, noToken: true };
  627. }
  628. try {
  629. // 先获取用户列表,找到用户的 ID(限制 100 条)
  630. const listResponse = await fetch(`${apiGetAll}?skip=0&take=100`, {
  631. headers: { 'Authorization': `Bearer ${token}` }
  632. });
  633. if (!listResponse.ok) {
  634. return { success: false, notFound: false };
  635. }
  636. const listData = await listResponse.json();
  637. // 根据用户名查找用户 ID
  638. const user = listData.data?.find((u: { username: string }) =>
  639. u.username === username
  640. );
  641. if (!user) {
  642. // 用户不在列表中,可能已被删除或在其他页
  643. return { success: false, notFound: true };
  644. }
  645. // 使用用户 ID 删除 - DELETE 方法
  646. const deleteResponse = await fetch(`${apiDelete}/${user.id}`, {
  647. method: 'DELETE',
  648. headers: {
  649. 'Authorization': `Bearer ${token}`,
  650. 'Content-Type': 'application/json'
  651. }
  652. });
  653. if (!deleteResponse.ok && deleteResponse.status !== 204) {
  654. return { success: false, notFound: false };
  655. }
  656. return { success: true };
  657. } catch (_error) {
  658. return { success: false, notFound: false };
  659. }
  660. }, {
  661. username,
  662. apiGetAll: UserManagementPage.API_GET_ALL_USERS,
  663. apiDelete: UserManagementPage.API_DELETE_USER
  664. }),
  665. // 10 秒超时
  666. new Promise((resolve) => setTimeout(() => resolve({ success: false, timeout: true }), 10000))
  667. ]) as Promise<{ success: boolean; notFound?: boolean; timeout?: boolean }>;
  668. // 如果超时:打印警告但返回 true(允许测试继续)
  669. if (result.timeout) {
  670. console.debug(`删除用户 "${username}" 超时,但允许测试继续`);
  671. return true;
  672. }
  673. // 如果用户找不到:认为删除成功(可能已被其他测试删除)
  674. if (result.notFound) {
  675. console.debug(`删除用户 "${username}": 用户不存在,认为已删除`);
  676. return true;
  677. }
  678. if (result.noToken) {
  679. console.debug('删除用户失败: 未找到认证 token');
  680. return false;
  681. }
  682. if (!result.success) {
  683. console.debug(`删除用户 "${username}" 失败: 未知错误`);
  684. return false;
  685. }
  686. // 删除成功后刷新页面,确保列表更新
  687. await this.page.reload();
  688. await this.page.waitForLoadState('domcontentloaded');
  689. return true;
  690. } catch (error) {
  691. console.debug(`删除用户 "${username}" 异常:`, error);
  692. // 发生异常时返回 true,避免阻塞测试
  693. return true;
  694. }
  695. }
  696. // ===== 搜索和验证方法 =====
  697. /**
  698. * 按用户名搜索
  699. * @param keyword 搜索关键词
  700. * @returns 搜索结果是否包含目标用户
  701. */
  702. async searchUsers(keyword: string): Promise<boolean> {
  703. await this.searchInput.fill(keyword);
  704. await this.searchButton.click();
  705. await this.page.waitForLoadState('domcontentloaded');
  706. await this.page.waitForTimeout(TIMEOUTS.LONG);
  707. // 验证搜索结果
  708. return await this.userExists(keyword);
  709. }
  710. /**
  711. * 验证用户是否存在(使用精确匹配)
  712. * @param username 用户名
  713. * @returns 用户是否存在
  714. */
  715. async userExists(username: string): Promise<boolean> {
  716. const userRow = this.userTable.locator('tbody tr').filter({ hasText: username });
  717. const count = await userRow.count();
  718. if (count === 0) return false;
  719. // 进一步验证用户名列的文本是否完全匹配
  720. // 表格列顺序:头像(0), 用户名(1), 昵称(2), 邮箱(3), 真实姓名(4), ...
  721. const nameCell = userRow.locator('td').nth(1);
  722. const actualText = await nameCell.textContent();
  723. return actualText?.trim() === username;
  724. }
  725. /**
  726. * 获取用户数量
  727. * @returns 用户数量
  728. */
  729. async getUserCount(): Promise<number> {
  730. const rows = await this.userTable.locator('tbody tr').count();
  731. return rows;
  732. }
  733. /**
  734. * 根据用户名获取用户行
  735. * @param username 用户名
  736. * @returns 用户行定位器
  737. */
  738. getUserByUsername(username: string): Locator {
  739. return this.userTable.locator('tbody tr').filter({ hasText: username });
  740. }
  741. /**
  742. * 期望用户存在
  743. * @param username 用户名
  744. */
  745. async expectUserExists(username: string): Promise<void> {
  746. const exists = await this.userExists(username);
  747. if (!exists) {
  748. throw new Error(`期望用户 "${username}" 存在,但未找到`);
  749. }
  750. }
  751. /**
  752. * 期望用户不存在
  753. * @param username 用户名
  754. */
  755. async expectUserNotExists(username: string): Promise<void> {
  756. const exists = await this.userExists(username);
  757. if (exists) {
  758. throw new Error(`期望用户 "${username}" 不存在,但找到了`);
  759. }
  760. }
  761. }