order-management.page.ts 57 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681
  1. import { TIMEOUTS } from '../../utils/timeouts';
  2. import { Page, Locator } from '@playwright/test';
  3. import { selectRadixOption } from '@d8d/e2e-test-utils';
  4. /**
  5. * 订单状态常量
  6. */
  7. export const ORDER_STATUS = {
  8. DRAFT: 'draft',
  9. CONFIRMED: 'confirmed',
  10. IN_PROGRESS: 'in_progress',
  11. COMPLETED: 'completed',
  12. } as const;
  13. /**
  14. * 订单状态类型
  15. */
  16. export type OrderStatus = typeof ORDER_STATUS[keyof typeof ORDER_STATUS];
  17. /**
  18. * 订单状态显示名称映射
  19. */
  20. export const ORDER_STATUS_LABELS: Record<OrderStatus, string> = {
  21. draft: '草稿',
  22. confirmed: '已确认',
  23. in_progress: '进行中',
  24. completed: '已完成',
  25. } as const;
  26. /**
  27. * 工作状态常量
  28. */
  29. export const WORK_STATUS = {
  30. NOT_WORKING: 'not_working',
  31. PRE_WORKING: 'pre_working',
  32. WORKING: 'working',
  33. RESIGNED: 'resigned',
  34. } as const;
  35. /**
  36. * 工作状态类型
  37. */
  38. export type WorkStatus = typeof WORK_STATUS[keyof typeof WORK_STATUS];
  39. /**
  40. * 工作状态显示名称映射
  41. */
  42. export const WORK_STATUS_LABELS: Record<WorkStatus, string> = {
  43. not_working: '未入职',
  44. pre_working: '已入职',
  45. working: '工作中',
  46. resigned: '已离职',
  47. } as const;
  48. /**
  49. * 订单数据接口
  50. */
  51. export interface OrderData {
  52. /** 订单名称 */
  53. name: string;
  54. /** 预计开始日期 */
  55. expectedStartDate?: string;
  56. /** 平台ID */
  57. platformId?: number;
  58. /** 平台名称 */
  59. platformName?: string;
  60. /** 公司ID */
  61. companyId?: number;
  62. /** 公司名称 */
  63. companyName?: string;
  64. /** 渠道ID */
  65. channelId?: number;
  66. /** 渠道名称 */
  67. channelName?: string;
  68. /** 订单状态 */
  69. status?: OrderStatus;
  70. /** 工作状态 */
  71. workStatus?: WorkStatus;
  72. }
  73. /**
  74. * 订单人员数据接口
  75. */
  76. export interface OrderPersonData {
  77. /** 残疾人ID */
  78. disabledPersonId: number;
  79. /** 残疾人姓名 */
  80. disabledPersonName?: string;
  81. /** 入职日期 */
  82. hireDate?: string;
  83. /** 薪资 */
  84. salary?: number;
  85. /** 工作状态 */
  86. workStatus?: WorkStatus;
  87. /** 实际入职日期 */
  88. actualHireDate?: string;
  89. /** 离职日期 */
  90. resignDate?: string;
  91. }
  92. /**
  93. * 网络响应数据接口
  94. */
  95. export interface NetworkResponse {
  96. /** 请求URL */
  97. url: string;
  98. /** 请求方法 */
  99. method: string;
  100. /** 响应状态码 */
  101. status: number;
  102. /** 是否成功 */
  103. ok: boolean;
  104. /** 响应头 */
  105. responseHeaders: Record<string, string>;
  106. /** 响应体 */
  107. responseBody: unknown;
  108. }
  109. /**
  110. * 表单提交结果接口
  111. */
  112. export interface FormSubmitResult {
  113. /** 提交是否成功 */
  114. success: boolean;
  115. /** 是否有错误 */
  116. hasError: boolean;
  117. /** 是否有成功消息 */
  118. hasSuccess: boolean;
  119. /** 错误消息 */
  120. errorMessage?: string;
  121. /** 成功消息 */
  122. successMessage?: string;
  123. /** 网络响应列表 */
  124. responses?: NetworkResponse[];
  125. }
  126. /**
  127. * 订单管理 Page Object
  128. *
  129. * 用于订单管理功能的 E2E 测试
  130. * 页面路径: /admin/orders(待确认)
  131. *
  132. * @example
  133. * ```typescript
  134. * const orderPage = new OrderManagementPage(page);
  135. * await orderPage.goto();
  136. * await orderPage.createOrder({ name: '测试订单' });
  137. * ```
  138. */
  139. export class OrderManagementPage {
  140. readonly page: Page;
  141. // ===== 页面级选择器 =====
  142. /** 页面标题 */
  143. readonly pageTitle: Locator;
  144. /** 新增订单按钮 */
  145. readonly addOrderButton: Locator;
  146. /** 订单列表表格 */
  147. readonly orderTable: Locator;
  148. /** 搜索输入框 */
  149. readonly searchInput: Locator;
  150. /** 搜索按钮 */
  151. readonly searchButton: Locator;
  152. constructor(page: Page) {
  153. this.page = page;
  154. // 初始化页面级选择器
  155. // 使用更精确的选择器来定位页面标题(避免与侧边栏按钮冲突)
  156. this.pageTitle = page.locator('[data-slot="card-title"]').getByText('订单管理', { exact: true });
  157. // 使用 data-testid 定位创建订单按钮(按钮文本是"创建订单"不是"新增订单")
  158. this.addOrderButton = page.getByTestId('create-order-button');
  159. this.orderTable = page.locator('table');
  160. // 使用 data-testid 定位搜索输入框
  161. this.searchInput = page.getByTestId('search-order-name-input');
  162. // 使用 data-testid 定位搜索按钮
  163. this.searchButton = page.getByTestId('search-button');
  164. }
  165. // ===== 导航和基础验证 =====
  166. /**
  167. * 导航到订单管理页面
  168. */
  169. async goto() {
  170. await this.page.goto('/admin/orders');
  171. await this.page.waitForLoadState('domcontentloaded');
  172. // 等待页面标题出现
  173. await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  174. // 等待表格数据加载
  175. await this.page.waitForSelector('table tbody tr', { state: 'visible', timeout: TIMEOUTS.PAGE_LOAD_LONG });
  176. await this.expectToBeVisible();
  177. }
  178. /**
  179. * 验证页面关键元素可见
  180. */
  181. async expectToBeVisible() {
  182. await this.pageTitle.waitFor({ state: 'visible', timeout: TIMEOUTS.PAGE_LOAD });
  183. await this.addOrderButton.waitFor({ state: 'visible', timeout: TIMEOUTS.TABLE_LOAD });
  184. }
  185. // ===== 搜索和筛选功能 =====
  186. /**
  187. * 按订单名称搜索
  188. * @param name 订单名称
  189. */
  190. async searchByName(name: string) {
  191. await this.searchInput.fill(name);
  192. await this.searchButton.click();
  193. await this.page.waitForLoadState('networkidle');
  194. await this.page.waitForTimeout(TIMEOUTS.LONG);
  195. }
  196. /**
  197. * 打开高级筛选对话框
  198. */
  199. async openFilterDialog() {
  200. const filterButton = this.page.getByRole('button', { name: /筛选|高级筛选/ });
  201. await filterButton.click();
  202. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  203. }
  204. /**
  205. * 设置筛选条件
  206. * @param filters 筛选条件
  207. */
  208. async setFilters(filters: {
  209. status?: OrderStatus;
  210. workStatus?: WorkStatus;
  211. platformId?: number;
  212. platformName?: string;
  213. companyId?: number;
  214. companyName?: string;
  215. channelId?: number;
  216. channelName?: string;
  217. dateRange?: { start?: string; end?: string };
  218. }) {
  219. // 订单状态筛选
  220. if (filters.status) {
  221. const statusFilter = this.page.getByLabel(/订单状态/);
  222. await statusFilter.click();
  223. const statusLabel = ORDER_STATUS_LABELS[filters.status];
  224. await this.page.getByRole('option', { name: statusLabel }).click();
  225. }
  226. // 工作状态筛选
  227. if (filters.workStatus) {
  228. const workStatusFilter = this.page.getByLabel(/工作状态/);
  229. await workStatusFilter.click();
  230. const workStatusLabel = WORK_STATUS_LABELS[filters.workStatus];
  231. await this.page.getByRole('option', { name: workStatusLabel }).click();
  232. }
  233. // 平台筛选
  234. if (filters.platformName) {
  235. await selectRadixOption(this.page, '平台', filters.platformName);
  236. }
  237. // 公司筛选
  238. if (filters.companyName) {
  239. await selectRadixOption(this.page, '公司', filters.companyName);
  240. }
  241. // 渠道筛选
  242. if (filters.channelName) {
  243. await selectRadixOption(this.page, '渠道', filters.channelName);
  244. }
  245. // 日期范围筛选
  246. if (filters.dateRange) {
  247. if (filters.dateRange.start) {
  248. const startDateInput = this.page.getByLabel(/开始日期|起始日期/);
  249. await startDateInput.fill(filters.dateRange.start);
  250. }
  251. if (filters.dateRange.end) {
  252. const endDateInput = this.page.getByLabel(/结束日期|截止日期/);
  253. await endDateInput.fill(filters.dateRange.end);
  254. }
  255. }
  256. }
  257. /**
  258. * 应用筛选条件
  259. */
  260. async applyFilters() {
  261. const applyButton = this.page.getByRole('button', { name: /应用|确定|筛选/ });
  262. await applyButton.click();
  263. await this.page.waitForLoadState('networkidle');
  264. await this.page.waitForTimeout(TIMEOUTS.LONG);
  265. }
  266. /**
  267. * 清空筛选条件
  268. */
  269. async clearFilters() {
  270. const clearButton = this.page.getByRole('button', { name: /重置|清空/ });
  271. await clearButton.click();
  272. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  273. }
  274. // ===== 订单 CRUD 操作 =====
  275. /**
  276. * 打开创建订单对话框
  277. */
  278. async openCreateDialog() {
  279. await this.addOrderButton.click();
  280. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  281. }
  282. /**
  283. * 打开编辑订单对话框
  284. * @param orderName 订单名称
  285. */
  286. async openEditDialog(orderName: string) {
  287. // 找到订单行并点击"打开菜单"按钮
  288. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  289. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  290. await menuButton.click();
  291. // 等待菜单出现并点击"编辑"选项
  292. // 使用 data-testid 或 role 定位编辑选项
  293. const editOption = this.page.getByRole('menuitem', { name: '编辑' });
  294. await editOption.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  295. await editOption.click();
  296. // 等待编辑对话框出现
  297. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  298. }
  299. /**
  300. * 打开删除确认对话框
  301. * @param orderName 订单名称
  302. */
  303. async openDeleteDialog(orderName: string) {
  304. // 找到订单行并点击"打开菜单"按钮(与编辑操作相同的模式)
  305. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  306. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  307. await menuButton.click();
  308. // 等待菜单出现并点击"删除"选项
  309. const deleteOption = this.page.getByRole('menuitem', { name: '删除' });
  310. await deleteOption.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  311. await deleteOption.click();
  312. // 等待删除确认对话框出现
  313. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  314. }
  315. /**
  316. * 填写订单表单
  317. * @param data 订单数据
  318. */
  319. async fillOrderForm(data: OrderData) {
  320. // 等待表单出现
  321. await this.page.waitForSelector('form', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  322. // 填写订单名称
  323. if (data.name) {
  324. await this.page.getByLabel(/订单名称|名称/).fill(data.name);
  325. }
  326. // 选择平台(必须在公司之前选择,因为公司列表依赖平台)
  327. if (data.platformName) {
  328. await selectRadixOption(this.page, '平台', data.platformName);
  329. }
  330. // 选择公司
  331. if (data.companyName) {
  332. await selectRadixOption(this.page, '公司', data.companyName);
  333. }
  334. // 选择渠道
  335. if (data.channelName) {
  336. await selectRadixOption(this.page, '渠道', data.channelName);
  337. }
  338. // 填写预计开始日期
  339. if (data.expectedStartDate) {
  340. const dateInput = this.page.getByLabel(/预计开始日期|开始日期/);
  341. await dateInput.fill(data.expectedStartDate);
  342. }
  343. // 选择订单状态(如果是编辑模式)
  344. if (data.status) {
  345. const statusLabel = ORDER_STATUS_LABELS[data.status];
  346. await selectRadixOption(this.page, '订单状态', statusLabel);
  347. }
  348. // 选择工作状态(如果是编辑模式)
  349. if (data.workStatus) {
  350. const workStatusLabel = WORK_STATUS_LABELS[data.workStatus];
  351. await selectRadixOption(this.page, '工作状态', workStatusLabel);
  352. }
  353. // 创建订单时需要至少选择一名残疾人
  354. // 如果表单中有"选择残疾人"按钮,点击它并选择第一个可用的残疾人
  355. const selectPersonButton = this.page.getByRole('button', { name: '选择残疾人' });
  356. const hasSelectPersonButton = await selectPersonButton.count();
  357. if (hasSelectPersonButton > 0) {
  358. console.debug('[创建订单] 检测到需要选择残疾人,点击"选择残疾人"按钮');
  359. await selectPersonButton.click();
  360. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  361. // 等待残疾人选择器对话框打开
  362. // 选择第一个可用的残疾人(通常是测试数据)
  363. // 尝试多种方式定位残疾人列表
  364. const firstCheckbox = this.page.locator('input[type="checkbox"]').first();
  365. const checkboxCount = await firstCheckbox.count();
  366. if (checkboxCount > 0) {
  367. // 使用第一个复选框
  368. await firstCheckbox.click();
  369. console.debug('[创建订单] 已选择第一个残疾人');
  370. // 查找确认按钮并点击(可能是"确定"、"确认"等)
  371. const confirmButton = this.page.getByRole('button', { name: /^(确定|确认|选择)$/ });
  372. const confirmCount = await confirmButton.count();
  373. if (confirmCount > 0) {
  374. await confirmButton.first().click();
  375. console.debug('[创建订单] 已确认选择残疾人');
  376. } else {
  377. // 如果没有确认按钮,尝试按 Enter 键
  378. await this.page.keyboard.press('Enter');
  379. console.debug('[创建订单] 按 Enter 键确认选择');
  380. }
  381. } else {
  382. console.debug('[创建订单] 未找到残疾人复选框,尝试其他方式');
  383. // 尝试查找残疾人列表项并点击第一个
  384. const firstPersonItem = this.page.locator('[role="option"], .option-item, .person-item').first();
  385. const itemCount = await firstPersonItem.count();
  386. if (itemCount > 0) {
  387. await firstPersonItem.click();
  388. console.debug('[创建订单] 已点击第一个残疾人选项');
  389. } else {
  390. // 如果还是没有,尝试关闭对话框并继续(有些实现可能有默认选择)
  391. console.debug('[创建订单] 未找到残疾人选项,尝试关闭对话框并继续');
  392. await this.page.keyboard.press('Escape');
  393. }
  394. }
  395. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  396. } else {
  397. console.debug('[创建订单] 未检测到"选择残疾人"按钮,可能已有人选或不在创建模式');
  398. }
  399. }
  400. /**
  401. * 提交表单
  402. * @returns 表单提交结果
  403. */
  404. async submitForm(): Promise<FormSubmitResult> {
  405. // 收集网络响应
  406. const responses: NetworkResponse[] = [];
  407. // 监听所有网络请求
  408. const responseHandler = async (response: Response) => {
  409. const url = response.url();
  410. // 监听订单管理相关的 API 请求
  411. if (url.includes('/orders') || url.includes('order')) {
  412. const _requestBody = response.request()?.postData();
  413. const responseBody = await response.text().catch(() => '');
  414. let jsonBody = null;
  415. try {
  416. jsonBody = JSON.parse(responseBody);
  417. } catch {
  418. // 不是 JSON 响应
  419. }
  420. responses.push({
  421. url,
  422. method: response.request()?.method() ?? 'UNKNOWN',
  423. status: response.status(),
  424. ok: response.ok(),
  425. responseHeaders: await response.allHeaders().catch(() => ({})),
  426. responseBody: jsonBody || responseBody,
  427. });
  428. }
  429. };
  430. this.page.on('response', responseHandler);
  431. try {
  432. // 点击提交按钮(创建或更新)
  433. const submitButton = this.page.getByRole('button', { name: /^(创建|更新|保存)$/ });
  434. await submitButton.click();
  435. // 等待网络请求完成(使用较宽松的超时,因为有些操作可能不触发网络请求)
  436. try {
  437. await this.page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.DIALOG });
  438. } catch {
  439. // domcontentloaded 超时不是致命错误,继续检查 Toast 消息
  440. console.debug('domcontentloaded 超时,继续检查 Toast 消息');
  441. }
  442. } finally {
  443. // 确保监听器总是被移除,防止内存泄漏
  444. this.page.off('response', responseHandler);
  445. }
  446. // 等待 Toast 消息显示
  447. await this.page.waitForTimeout(TIMEOUTS.VERY_LONG);
  448. // 检查 Toast 消息
  449. const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
  450. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  451. const hasError = await errorToast.count() > 0;
  452. const hasSuccess = await successToast.count() > 0;
  453. let errorMessage: string | null = null;
  454. let successMessage: string | null = null;
  455. if (hasError) {
  456. errorMessage = await errorToast.first().textContent();
  457. }
  458. if (hasSuccess) {
  459. successMessage = await successToast.first().textContent();
  460. }
  461. return {
  462. success: hasSuccess || (!hasError && !hasSuccess),
  463. hasError,
  464. hasSuccess,
  465. errorMessage: errorMessage ?? undefined,
  466. successMessage: successMessage ?? undefined,
  467. responses,
  468. };
  469. }
  470. /**
  471. * 取消对话框
  472. */
  473. async cancelDialog() {
  474. const cancelButton = this.page.getByRole('button', { name: '取消' });
  475. await cancelButton.click();
  476. await this.waitForDialogClosed();
  477. }
  478. /**
  479. * 等待对话框关闭
  480. */
  481. async waitForDialogClosed() {
  482. // 先等待一段时间让对话框有机会关闭
  483. await this.page.waitForTimeout(TIMEOUTS.LONG);
  484. // 检查是否还有对话框可见
  485. const dialogs = this.page.locator('[role="dialog"]');
  486. const dialogCount = await dialogs.count();
  487. if (dialogCount === 0) {
  488. // 没有对话框了,已经关闭
  489. console.debug('对话框已关闭(无对话框元素)');
  490. return;
  491. }
  492. // 尝试等待对话框隐藏或从 DOM 中移除
  493. try {
  494. await dialogs.first().waitFor({ state: 'hidden', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  495. console.debug('对话框已关闭');
  496. } catch {
  497. // 超时不是致命错误,对话框可能已经以其他方式关闭
  498. console.debug('对话框关闭等待超时,继续执行');
  499. }
  500. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  501. }
  502. /**
  503. * 确认删除操作
  504. */
  505. async confirmDelete() {
  506. // 尝试多种可能的按钮名称
  507. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', {
  508. name: /^(确认删除|删除|确定|确认)$/
  509. });
  510. await confirmButton.click();
  511. // 等待确认对话框关闭和网络请求完成
  512. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  513. .catch(() => console.debug('删除确认对话框关闭超时'));
  514. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.TABLE_LOAD });
  515. await this.page.waitForTimeout(TIMEOUTS.LONG);
  516. }
  517. /**
  518. * 取消删除操作
  519. */
  520. async cancelDelete() {
  521. // 先定位到 alertdialog,然后在其中查找取消按钮
  522. const dialog = this.page.locator('[role="alertdialog"]');
  523. const cancelButton = dialog.getByRole('button', { name: '取消' });
  524. await cancelButton.click();
  525. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  526. .catch(() => console.debug('删除确认对话框关闭超时(取消操作)'));
  527. }
  528. /**
  529. * 验证订单是否存在
  530. * @param orderName 订单名称
  531. * @returns 订单是否存在
  532. */
  533. async orderExists(orderName: string): Promise<boolean> {
  534. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  535. return (await orderRow.count()) > 0;
  536. }
  537. // ===== 订单详情 =====
  538. /**
  539. * 打开订单详情对话框
  540. * @param orderName 订单名称
  541. */
  542. async openDetailDialog(orderName: string) {
  543. // 找到订单行
  544. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  545. // 先点击操作菜单触发按钮("打开菜单" 或 MoreHorizontal 图标)
  546. const menuTrigger = orderRow.getByRole('button', { name: /打开菜单/ });
  547. await menuTrigger.click();
  548. // 等待菜单显示
  549. await this.page.waitForTimeout(TIMEOUTS.VERY_SHORT);
  550. // 点击"查看详情"菜单项
  551. const detailButton = this.page.getByRole('menuitem', { name: /查看详情/ });
  552. await detailButton.click();
  553. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  554. }
  555. /**
  556. * 获取订单详情对话框中的基本信息
  557. * @returns 订单基本信息
  558. */
  559. async getOrderDetailInfo(): Promise<{
  560. name?: string;
  561. status?: string;
  562. workStatus?: string;
  563. expectedStartDate?: string;
  564. platform?: string;
  565. company?: string;
  566. channel?: string;
  567. }> {
  568. const dialog = this.page.locator('[role="dialog"]');
  569. const result: Record<string, string | undefined> = {};
  570. // 使用 data-testid 直接定位元素(更可靠)
  571. // DOM 结构: <div className="flex items-center justify-between">
  572. // <span className="text-sm font-medium">标签:</span>
  573. // <span data-testid="order-detail-xxx">值</span>
  574. // </div>
  575. // 订单名称 - 使用 data-testid
  576. const nameElement = dialog.locator('[data-testid="order-detail-name"]');
  577. if (await nameElement.count() > 0) {
  578. result.name = (await nameElement.textContent())?.trim();
  579. }
  580. // 订单状态
  581. const statusElement = dialog.locator('[data-testid="order-detail-status"]');
  582. if (await statusElement.count() > 0) {
  583. result.status = (await statusElement.textContent())?.trim();
  584. }
  585. // 工作状态 - 查找包含"工作状态"标签的行
  586. const workStatusRow = dialog.locator('div').filter({ hasText: /工作状态:/ }).first();
  587. if (await workStatusRow.count() > 0) {
  588. const workStatusElement = workStatusRow.locator('span').nth(1);
  589. result.workStatus = (await workStatusElement.textContent())?.trim();
  590. }
  591. // 预计开始日期 - 使用 data-testid
  592. const expectedStartDateElement = dialog.locator('[data-testid="order-detail-expected-start"]');
  593. if (await expectedStartDateElement.count() > 0) {
  594. result.expectedStartDate = (await expectedStartDateElement.textContent())?.trim();
  595. }
  596. // 平台 - 使用 data-testid
  597. const platformElement = dialog.locator('[data-testid="order-detail-platform"]');
  598. if (await platformElement.count() > 0) {
  599. result.platform = (await platformElement.textContent())?.trim();
  600. }
  601. // 公司 - 使用 data-testid
  602. const companyElement = dialog.locator('[data-testid="order-detail-company"]');
  603. if (await companyElement.count() > 0) {
  604. result.company = (await companyElement.textContent())?.trim();
  605. }
  606. // 渠道 - 使用 data-testid
  607. const channelElement = dialog.locator('[data-testid="order-detail-channel"]');
  608. if (await channelElement.count() > 0) {
  609. result.channel = (await channelElement.textContent())?.trim();
  610. }
  611. return result;
  612. }
  613. /**
  614. * 从订单详情对话框中获取关联人员列表
  615. * @returns 人员信息列表
  616. */
  617. async getPersonListFromDetail(): Promise<Array<{
  618. name?: string;
  619. workStatus?: string;
  620. hireDate?: string;
  621. salary?: string;
  622. }>> {
  623. const dialog = this.page.locator('[role="dialog"]');
  624. const result: Array<{ name?: string; workStatus?: string; hireDate?: string; salary?: string }> = [];
  625. // 查找所有表格,对话框中可能有两个表格:
  626. // 1. "待添加人员列表" - 临时表格,包含未确认的人员
  627. // 2. "绑定人员列表" - 实际已绑定到订单的人员
  628. // 我们需要第二个"绑定人员列表"表格
  629. const allTables = dialog.locator('table');
  630. const tableCount = await allTables.count();
  631. // 查找"绑定人员列表"表格(通常是包含"工作状态"列的表格)
  632. let personTable;
  633. for (let i = 0; i < tableCount; i++) {
  634. const table = allTables.nth(i);
  635. const tableText = await table.textContent();
  636. // 绑定人员列表表格包含"工作状态"列,而待添加人员列表没有
  637. if (tableText && tableText.includes('工作状态')) {
  638. personTable = table;
  639. break;
  640. }
  641. }
  642. const personList = dialog.locator('[class*="person"], [class*="employee"], [data-testid*="person"]');
  643. // 优先使用表格形式
  644. if (personTable) {
  645. const rows = personTable.locator('tbody tr');
  646. const rowCount = await rows.count();
  647. for (let i = 0; i < rowCount; i++) {
  648. const row = rows.nth(i);
  649. const cells = row.locator('td');
  650. const cellCount = await cells.count();
  651. const personInfo: { name?: string; workStatus?: string; hireDate?: string; salary?: string } = {};
  652. // 根据列数量和数据类型提取信息
  653. // 表格列:ID 姓名 性别 残疾类型 联系电话 入职日期 离职日期 工作状态 薪资
  654. for (let j = 0; j < cellCount; j++) {
  655. const cellText = await cells.nth(j).textContent();
  656. if (!cellText) continue;
  657. const trimmedText = cellText.trim();
  658. // 尝试识别列内容
  659. // ID 在第一列(j === 0),姓名在第二列(j === 1)
  660. if (j === 1 && trimmedText) {
  661. personInfo.name = trimmedText;
  662. }
  663. // 工作状态检查
  664. for (const [_statusValue, statusLabel] of Object.entries(WORK_STATUS_LABELS)) {
  665. if (trimmedText.includes(statusLabel)) {
  666. personInfo.workStatus = statusLabel;
  667. break;
  668. }
  669. }
  670. // 日期检查(符合日期格式)
  671. if (/^\d{4}-\d{2}-\d{2}$/.test(trimmedText) || /^\d{4}\/\d{2}\/\d{2}$/.test(trimmedText)) {
  672. if (!personInfo.hireDate) {
  673. personInfo.hireDate = trimmedText;
  674. }
  675. }
  676. // 薪资检查(在最后一列,包含数字且可能是薪资)
  677. // 薪资通常是较大的数字,不应该是11位电话号码
  678. if (j === cellCount - 1 && /^\d+(\.\d+)?$/.test(trimmedText.replace(/,/g, ''))) {
  679. const numValue = trimmedText.replace(/,/g, '');
  680. // 排除11位电话号码(如13800019729)
  681. if (numValue.length < 11) {
  682. personInfo.salary = trimmedText;
  683. }
  684. }
  685. }
  686. if (personInfo.name || personInfo.workStatus) {
  687. result.push(personInfo);
  688. }
  689. }
  690. } else if (await personList.count() > 0) {
  691. // 如果是列表形式而非表格
  692. const listItems = personList.locator('[class*="item"], [class*="row"], li, div');
  693. const itemCount = await listItems.count();
  694. for (let i = 0; i < itemCount; i++) {
  695. const item = listItems.nth(i);
  696. const itemText = await item.textContent();
  697. if (itemText && itemText.trim()) {
  698. result.push({ name: itemText.trim() });
  699. }
  700. }
  701. }
  702. return result;
  703. }
  704. /**
  705. * 从订单详情对话框中获取附件列表
  706. * @returns 附件信息列表
  707. */
  708. async getAttachmentListFromDetail(): Promise<Array<{
  709. fileName?: string;
  710. uploadDate?: string;
  711. uploader?: string;
  712. }>> {
  713. const dialog = this.page.locator('[role="dialog"]');
  714. const result: Array<{ fileName?: string; uploadDate?: string; uploader?: string }> = [];
  715. // 查找附件列表区域
  716. // 尝试多种可能的定位策略
  717. const attachmentTable = dialog.locator('table').filter({ hasText: /附件|文件/ });
  718. const attachmentList = dialog.locator('[class*="attachment"], [class*="file"], [data-testid*="attachment"]');
  719. // 优先使用表格形式
  720. if (await attachmentTable.count() > 0) {
  721. const rows = attachmentTable.locator('tbody tr');
  722. const rowCount = await rows.count();
  723. for (let i = 0; i < rowCount; i++) {
  724. const row = rows.nth(i);
  725. const cells = row.locator('td');
  726. const cellCount = await cells.count();
  727. const attachmentInfo: { fileName?: string; uploadDate?: string; uploader?: string } = {};
  728. for (let j = 0; j < cellCount; j++) {
  729. const cellText = await cells.nth(j).textContent();
  730. if (!cellText) continue;
  731. const trimmedText = cellText.trim();
  732. // 文件名通常在第一列
  733. if (j === 0 && trimmedText) {
  734. attachmentInfo.fileName = trimmedText;
  735. }
  736. // 日期检查
  737. if (/^\d{4}-\d{2}-\d{2}/.test(trimmedText) || /^\d{4}\/\d{2}\/\d{2}/.test(trimmedText)) {
  738. if (!attachmentInfo.uploadDate) {
  739. attachmentInfo.uploadDate = trimmedText;
  740. }
  741. }
  742. // 上传者通常是文本用户名
  743. if (j > 0 && trimmedText && !attachmentInfo.uploader && !attachmentInfo.uploadDate && !/^\d{4}/.test(trimmedText)) {
  744. attachmentInfo.uploader = trimmedText;
  745. }
  746. }
  747. if (attachmentInfo.fileName) {
  748. result.push(attachmentInfo);
  749. }
  750. }
  751. } else if (await attachmentList.count() > 0) {
  752. // 如果是列表形式
  753. const listItems = attachmentList.locator('[class*="item"], [class*="row"], li, div');
  754. const itemCount = await listItems.count();
  755. for (let i = 0; i < itemCount; i++) {
  756. const item = listItems.nth(i);
  757. const itemText = await item.textContent();
  758. if (itemText && itemText.trim()) {
  759. result.push({ fileName: itemText.trim() });
  760. }
  761. }
  762. }
  763. return result;
  764. }
  765. /**
  766. * 关闭订单详情对话框
  767. */
  768. async closeDetailDialog(): Promise<void> {
  769. // 尝试多种关闭方式
  770. // 方式1: 点击右上角 X 按钮
  771. const closeButton = this.page.locator('[role="dialog"]').getByRole('button', { name: '关闭' }).first();
  772. const closeButtonCount = await closeButton.count();
  773. if (closeButtonCount > 0) {
  774. await closeButton.click();
  775. } else {
  776. // 方式2: 点击取消按钮
  777. const cancelButton = this.page.locator('[role="dialog"]').getByRole('button', { name: '取消' }).first();
  778. const cancelButtonCount = await cancelButton.count();
  779. if (cancelButtonCount > 0) {
  780. await cancelButton.click();
  781. } else {
  782. // 方式3: 按 Escape 键
  783. await this.page.keyboard.press('Escape');
  784. }
  785. }
  786. // 等待对话框关闭
  787. await this.waitForDialogClosed();
  788. }
  789. // ===== 人员关联管理 =====
  790. /**
  791. * 打开人员管理对话框
  792. *
  793. * **使用场景:**
  794. * - **从订单列表页打开**: 传入 `orderName` 参数,方法会先找到对应订单行,再点击人员管理按钮
  795. * - **从订单详情页打开**: 不传参数,方法会直接点击页面中的人员管理按钮
  796. *
  797. * @param orderName 订单名称(可选)。从列表页打开时需要传入,从详情页打开时不传
  798. *
  799. * @example
  800. * ```typescript
  801. * // 从订单列表页打开
  802. * await orderPage.openPersonManagementDialog('测试订单');
  803. *
  804. * // 从订单详情页打开
  805. * await orderPage.openDetailDialog('测试订单');
  806. * await orderPage.openPersonManagementDialog();
  807. * ```
  808. */
  809. async openPersonManagementDialog(orderName?: string) {
  810. // 人员管理功能直接集成在订单详情对话框中
  811. // 如果提供了订单名称,打开订单详情对话框
  812. if (orderName) {
  813. await this.openDetailDialog(orderName);
  814. }
  815. // 人员管理功能已在详情对话框中,无需额外操作
  816. }
  817. /**
  818. * 添加人员到订单
  819. * @param personData 人员数据
  820. */
  821. async addPersonToOrder(personData: OrderPersonData) {
  822. // 点击添加人员按钮
  823. const addButton = this.page.getByRole('button', { name: /添加人员|新增人员/ });
  824. await addButton.click();
  825. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  826. // 选择残疾人(支持通过名称选择)
  827. if (personData.disabledPersonName) {
  828. await selectRadixOption(this.page, '残疾人|选择残疾人', personData.disabledPersonName);
  829. } else if (personData.disabledPersonId) {
  830. // 如果只提供了 ID,尝试在对话框中选择第一个残疾人
  831. const firstCheckbox = this.page.locator('[role="dialog"]').locator('table tbody tr').first().locator('input[type="checkbox"]').first();
  832. try {
  833. await firstCheckbox.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  834. await firstCheckbox.check();
  835. } catch {
  836. console.debug('没有可用的残疾人数据');
  837. }
  838. }
  839. // 填写入职日期
  840. if (personData.hireDate) {
  841. const hireDateInput = this.page.getByLabel(/入职日期/);
  842. await hireDateInput.fill(personData.hireDate);
  843. }
  844. // 填写薪资
  845. if (personData.salary !== undefined) {
  846. const salaryInput = this.page.getByLabel(/薪资|工资/);
  847. await salaryInput.fill(String(personData.salary));
  848. }
  849. // 选择工作状态
  850. if (personData.workStatus) {
  851. const workStatusLabel = WORK_STATUS_LABELS[personData.workStatus];
  852. await selectRadixOption(this.page, '工作状态', workStatusLabel);
  853. }
  854. // 提交
  855. const submitButton = this.page.getByRole('button', { name: /^(添加|确定|保存)$/ });
  856. await submitButton.click();
  857. await this.page.waitForLoadState('networkidle');
  858. await this.page.waitForTimeout(TIMEOUTS.LONG);
  859. }
  860. /**
  861. * 修改人员工作状态
  862. * @param personName 人员姓名
  863. * @param newStatus 新的工作状态
  864. */
  865. async updatePersonWorkStatus(personName: string, newStatus: WorkStatus) {
  866. const dialog = this.page.locator('[role="dialog"]');
  867. // 等待对话框完全加载
  868. await dialog.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  869. // 从 error-context.md 可知:
  870. // 1. 对话框中有"绑定人员列表"表格
  871. // 2. 表格列:ID 姓名 性别 残疾类型 联系电话 入职日期 离职日期 工作状态 薪资
  872. // 3. 工作状态列直接是 combobox,不需要点击编辑按钮
  873. // 查找所有表格
  874. const allTables = dialog.locator('table');
  875. const allTableCount = await allTables.count();
  876. console.debug(`对话框中总共有 ${allTableCount} 个表格`);
  877. let personTable = allTables.first();
  878. // 找到包含"绑定人员"或"工作状态"列的表格(第二个表格是绑定人员列表)
  879. for (let i = 0; i < allTableCount; i++) {
  880. const table = allTables.nth(i);
  881. const tableText = await table.textContent();
  882. if (tableText && (tableText.includes('绑定人员') || tableText.includes('工作状态'))) {
  883. personTable = table;
  884. console.debug(`找到人员表格(索引 ${i})`);
  885. break;
  886. }
  887. }
  888. // 在表格中查找包含指定人员名称的行
  889. const targetRow = personTable.locator('tbody tr').filter({ hasText: personName }).first();
  890. const rowCount = await targetRow.count();
  891. console.debug(`找到 ${rowCount} 个匹配的人员行`);
  892. if (rowCount === 0) {
  893. throw new Error(`未找到人员 ${personName}`);
  894. }
  895. // 等待行可见
  896. await targetRow.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  897. // 从 error-context.md 可知,工作状态在单元格中是一个 combobox
  898. // 表格列:ID 姓名 性别 残疾类型 联系电话 入职日期 离职日期 工作状态 薪资
  899. // 工作状态是倒数第二列(薪资是最后一列)
  900. const cells = targetRow.locator('td');
  901. const cellCount = await cells.count();
  902. console.debug(`人员行有 ${cellCount} 个单元格`);
  903. // 工作状态在倒数第二列
  904. const workStatusCell = cells.nth(cellCount - 2);
  905. const workStatusCombobox = workStatusCell.getByRole('combobox');
  906. const comboboxCount = await workStatusCombobox.count();
  907. console.debug(`工作状态 combobox 数量: ${comboboxCount}`);
  908. if (comboboxCount === 0) {
  909. throw new Error(`未找到人员 ${personName} 的工作状态选择器`);
  910. }
  911. await workStatusCombobox.click({ timeout: TIMEOUTS.DIALOG });
  912. console.debug('工作状态 combobox 已点击');
  913. // 等待下拉选项显示
  914. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  915. // 使用中文标签选择选项
  916. // 注意:UI 中的工作状态选项与 WORK_STATUS_LABELS 不同
  917. // UI 选项:未入职、已入职、工作中、已离职
  918. // WORK_STATUS_LABELS:未就业、待就业、已就业、已离职
  919. const statusMapping: Record<WorkStatus, string> = {
  920. not_working: '未入职',
  921. pre_working: '已入职',
  922. working: '工作中',
  923. resigned: '已离职',
  924. };
  925. const newWorkStatusLabel = statusMapping[newStatus];
  926. console.debug(`尝试选择状态: ${newWorkStatusLabel}`);
  927. const optionLocator = this.page.getByRole('option', { name: newWorkStatusLabel });
  928. const optionCount = await optionLocator.count();
  929. console.debug(`找到 ${optionCount} 个选项`);
  930. if (optionCount === 0) {
  931. throw new Error(`未找到工作状态选项: ${newWorkStatusLabel}`);
  932. }
  933. await optionLocator.first().click({ timeout: TIMEOUTS.DIALOG });
  934. console.debug(`工作状态已更新为: ${newWorkStatusLabel}`);
  935. // 使用较短的超时时间等待网络空闲
  936. await this.page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.DIALOG })
  937. .catch(() => console.debug('domcontentloaded 等待超时,继续'));
  938. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  939. }
  940. // ===== 附件管理 =====
  941. /**
  942. * 打开资源上传对话框
  943. * "资源上传"按钮在订单详情对话框中
  944. */
  945. async openAddAttachmentDialog() {
  946. // 使用"资源上传"按钮
  947. const attachmentButton = this.page.getByRole('button', { name: /资源上传/ });
  948. await attachmentButton.click();
  949. // 等待第二个对话框(资源上传对话框)打开
  950. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  951. }
  952. /**
  953. * 上传附件
  954. *
  955. * 实际的 UI 流程:
  956. * 1. 在资源上传对话框中,点击人员行中对应文件类型的"上传文件"按钮
  957. * 2. 打开上传弹窗(第三个对话框)- 选择文件类型
  958. * 3. 在上传弹窗中,点击 FileSelector 的触发按钮
  959. * 4. 打开 FileSelector 对话框(第四个对话框)
  960. * 5. 在 FileSelector 对话框中使用 uploadFileToField 上传文件
  961. * 6. 点击上传后的文件进行选择
  962. * 7. 点击"确认选择"按钮
  963. * 8. 在上传弹窗中点击"提交"按钮
  964. *
  965. * @param personIdentifier 人员标识(可以是 ID 或姓名,方法会自动匹配)
  966. * @param fileName 文件名(相对于 web/tests/fixtures 目录)
  967. * @param mimeType 文件类型(默认为 image/jpeg,未使用,保留用于未来扩展)
  968. * @param fileType 文件类型(税务文件、薪资单、工作成果、合同签署、残疾证明、其他),默认为"其他"
  969. */
  970. async uploadAttachment(
  971. personIdentifier: string,
  972. fileName: string,
  973. _mimeType: string = 'image/jpeg',
  974. fileType: string = '其他'
  975. ) {
  976. // 动态导入 uploadFileToField 工具
  977. const { uploadFileToField } = await import('@d8d/e2e-test-utils');
  978. // 找到资源上传对话框(第二个对话框)
  979. const dialogs = this.page.locator('[role="dialog"]');
  980. const uploadDialog = dialogs.nth(1);
  981. // 在对话框中找到对应残疾人的行
  982. // 使用 ID 或姓名匹配
  983. const personRow = uploadDialog.locator('tr').filter({ hasText: personIdentifier });
  984. const rowCount = await personRow.count();
  985. if (rowCount === 0) {
  986. console.debug(`未找到人员 ${personIdentifier} 的行`);
  987. // 尝试打印所有行内容用于调试
  988. const allRows = uploadDialog.locator('tbody tr');
  989. const allRowCount = await allRows.count();
  990. console.debug(`资源上传对话框共有 ${allRowCount} 行`);
  991. for (let i = 0; i < Math.min(allRowCount, 3); i++) {
  992. const rowText = await allRows.nth(i).textContent();
  993. console.debug(`行 ${i} 内容:`, rowText);
  994. }
  995. return;
  996. }
  997. console.debug(`找到人员 ${personIdentifier} 的行`);
  998. // 在该人员行中找到对应文件类型的"上传文件"按钮
  999. // 文件类型列顺序:税务文件、薪资单、工作成果、合同签署、残疾证明、其他
  1000. const uploadButton = personRow.getByRole('button', { name: '上传文件' });
  1001. const buttonCount = await uploadButton.count();
  1002. if (buttonCount === 0) {
  1003. console.debug(`未找到"上传文件"按钮`);
  1004. return;
  1005. }
  1006. console.debug(`找到 ${buttonCount} 个"上传文件"按钮`);
  1007. // 根据文件类型选择对应的上传按钮
  1008. let buttonIndex = 5; // 默认为"其他"(最后一个)
  1009. switch (fileType) {
  1010. case '税务文件':
  1011. buttonIndex = 0;
  1012. break;
  1013. case '薪资单':
  1014. buttonIndex = 1;
  1015. break;
  1016. case '工作成果':
  1017. buttonIndex = 2;
  1018. break;
  1019. case '合同签署':
  1020. buttonIndex = 3;
  1021. break;
  1022. case '残疾证明':
  1023. buttonIndex = 4;
  1024. break;
  1025. case '其他':
  1026. default:
  1027. buttonIndex = 5;
  1028. break;
  1029. }
  1030. // 点击对应的上传文件按钮,这会打开上传弹窗(第三个对话框)
  1031. const targetButton = uploadButton.nth(buttonIndex);
  1032. // 调试信息:检查按钮是否可见
  1033. const isVisible = await targetButton.isVisible().catch(() => false);
  1034. console.debug(`目标上传文件按钮可见性: ${isVisible}`);
  1035. if (!isVisible) {
  1036. console.debug(`上传文件按钮不可见,尝试滚动到视图`);
  1037. await personRow.scrollIntoViewIfNeeded();
  1038. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  1039. }
  1040. await targetButton.click();
  1041. console.debug(`已点击第 ${buttonIndex} 个上传文件按钮`);
  1042. // 等待上传弹窗打开(第三个对话框)
  1043. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  1044. // 在上传弹窗中点击 FileSelector 的触发按钮
  1045. // FileSelector 组件的触发按钮文本是"选择或上传文件"
  1046. const fileSelectorTrigger = this.page.getByRole('button', { name: /选择或上传文件/ }).or(
  1047. this.page.getByText('选择或上传文件')
  1048. );
  1049. const triggerCount = await fileSelectorTrigger.count();
  1050. console.debug(`找到 ${triggerCount} 个 FileSelector 触发按钮`);
  1051. if (triggerCount === 0) {
  1052. console.debug('未找到 FileSelector 触发按钮');
  1053. return;
  1054. }
  1055. // 点击最新的 FileSelector 触发按钮(如果有多于一个的话)
  1056. await fileSelectorTrigger.nth(triggerCount - 1).click();
  1057. console.debug('已点击 FileSelector 触发按钮');
  1058. // FileSelector 对话框的 data-testid 是 "file-selector-dialog"
  1059. const fileSelectorDialog = this.page.getByTestId('file-selector-dialog');
  1060. // 等待 FileSelector 对话框打开(第四个对话框)
  1061. try {
  1062. await fileSelectorDialog.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  1063. console.debug('FileSelector 对话框已打开');
  1064. } catch (_error) {
  1065. console.debug('FileSelector 对话框未打开(超时)');
  1066. // 打印调试信息
  1067. const allDialogs = await this.page.locator('[role="dialog"]').count();
  1068. console.debug(`当前页面对话框数量: ${allDialogs}`);
  1069. return;
  1070. }
  1071. // 使用 uploadFileToField 上传文件
  1072. // MinioUploader 使用 data-testid="minio-uploader-input"(因为 testId 未被传递)
  1073. try {
  1074. await uploadFileToField(
  1075. this.page,
  1076. '[data-testid="minio-uploader-input"]',
  1077. fileName,
  1078. {
  1079. fixturesDir: 'tests/fixtures',
  1080. timeout: TIMEOUTS.DIALOG
  1081. }
  1082. );
  1083. console.debug(`文件 ${fileName} 上传操作已完成`);
  1084. } catch (uploadError) {
  1085. console.debug('文件上传失败:', uploadError);
  1086. // 即使上传失败,也尝试关闭对话框
  1087. await fileSelectorDialog.getByRole('button', { name: '取消' }).click().catch(() => {});
  1088. return;
  1089. }
  1090. // 等待上传处理完成,等待文件出现在对话框中
  1091. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1092. // 点击上传后的文件进行选择(查找第一个可点击的文件)
  1093. // 上传成功后,文件会被添加到文件列表中并显示为选中状态(border-primary)
  1094. const uploadedFile = fileSelectorDialog.locator('.border-primary').or(
  1095. fileSelectorDialog.locator('img').first()
  1096. );
  1097. const fileExists = await uploadedFile.count() > 0;
  1098. if (fileExists) {
  1099. await uploadedFile.first().click();
  1100. console.debug('已点击上传后的文件');
  1101. } else {
  1102. // 如果没找到选中的文件,尝试查看对话框内容
  1103. const allImages = fileSelectorDialog.locator('img');
  1104. const imgCount = await allImages.count();
  1105. console.debug(`对话框中图片数量: ${imgCount}`);
  1106. // 尝试点击任何图片
  1107. if (imgCount > 0) {
  1108. await allImages.first().click();
  1109. console.debug('已点击第一张图片');
  1110. }
  1111. }
  1112. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  1113. // 点击"确认选择"按钮
  1114. const confirmButton = fileSelectorDialog.getByRole('button', { name: '确认选择' });
  1115. const confirmButtonCount = await confirmButton.count();
  1116. if (confirmButtonCount > 0) {
  1117. await confirmButton.click();
  1118. console.debug('已点击确认选择按钮');
  1119. } else {
  1120. console.debug('未找到"确认选择"按钮');
  1121. }
  1122. // 等待 FileSelector 对话框关闭(回到上传弹窗)
  1123. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  1124. // 在上传弹窗中点击"提交"按钮
  1125. const submitButton = this.page.getByRole('button', { name: /^提交$/ });
  1126. const submitButtonCount = await submitButton.count();
  1127. if (submitButtonCount > 0) {
  1128. await submitButton.click();
  1129. console.debug('已点击提交按钮');
  1130. // 等待提交完成(上传弹窗关闭)
  1131. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1132. } else {
  1133. console.debug('未找到提交按钮');
  1134. }
  1135. console.debug(`附件上传流程完成: ${fileName}`);
  1136. }
  1137. /**
  1138. * 关闭资源上传对话框
  1139. */
  1140. async closeUploadDialog() {
  1141. // 资源上传对话框有"关闭"按钮
  1142. // 需要精确定位到第二个对话框(资源上传对话框)的关闭按钮
  1143. const dialogs = this.page.locator('[role="dialog"]');
  1144. const dialogCount = await dialogs.count();
  1145. if (dialogCount >= 2) {
  1146. // 资源上传对话框通常是第二个对话框
  1147. const uploadDialog = dialogs.nth(1);
  1148. const closeButton = uploadDialog.getByRole('button', { name: '关闭' });
  1149. const buttonCount = await closeButton.count();
  1150. if (buttonCount > 0) {
  1151. await closeButton.first().click();
  1152. console.debug('已关闭资源上传对话框');
  1153. } else {
  1154. console.debug('未找到资源上传对话框的关闭按钮,尝试按 Escape');
  1155. await this.page.keyboard.press('Escape');
  1156. }
  1157. } else {
  1158. // 如果只有一个对话框,尝试点击通用的关闭按钮
  1159. const closeButton = this.page.getByRole('button', { name: '关闭' });
  1160. const buttonCount = await closeButton.count();
  1161. if (buttonCount > 0) {
  1162. await closeButton.first().click();
  1163. console.debug('已关闭对话框(使用通用关闭按钮)');
  1164. }
  1165. }
  1166. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  1167. }
  1168. // ===== 高级操作 =====
  1169. /**
  1170. * 创建订单(完整流程)
  1171. * @param data 订单数据
  1172. * @returns 表单提交结果
  1173. */
  1174. async createOrder(data: OrderData): Promise<FormSubmitResult> {
  1175. await this.openCreateDialog();
  1176. await this.fillOrderForm(data);
  1177. const result = await this.submitForm();
  1178. await this.waitForDialogClosed();
  1179. return result;
  1180. }
  1181. /**
  1182. * 编辑订单(完整流程)
  1183. * @param orderName 订单名称
  1184. * @param data 更新的订单数据
  1185. * @returns 表单提交结果
  1186. */
  1187. async editOrder(orderName: string, data: OrderData): Promise<FormSubmitResult> {
  1188. await this.openEditDialog(orderName);
  1189. await this.fillOrderForm(data);
  1190. const result = await this.submitForm();
  1191. await this.waitForDialogClosed();
  1192. return result;
  1193. }
  1194. /**
  1195. * 删除订单(完整流程)
  1196. * @param orderName 订单名称
  1197. * @returns 是否成功删除
  1198. */
  1199. async deleteOrder(orderName: string): Promise<boolean> {
  1200. await this.openDeleteDialog(orderName);
  1201. await this.confirmDelete();
  1202. // 等待并检查 Toast 消息
  1203. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1204. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  1205. const hasSuccess = await successToast.count() > 0;
  1206. return hasSuccess;
  1207. }
  1208. // ===== 订单状态流转操作 =====
  1209. /**
  1210. * 打开激活订单确认对话框
  1211. * @param orderName 订单名称
  1212. */
  1213. async openActivateDialog(orderName: string): Promise<void> {
  1214. // 找到订单行并点击"打开菜单"按钮(与编辑/删除操作相同的模式)
  1215. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  1216. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1217. await menuButton.click();
  1218. // 等待菜单出现并点击"激活"选项
  1219. const activateOption = this.page.getByRole('menuitem', { name: /激活|激活订单/ });
  1220. await activateOption.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  1221. await activateOption.click();
  1222. // 等待确认对话框出现
  1223. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  1224. }
  1225. /**
  1226. * 确认激活订单
  1227. */
  1228. async confirmActivate(): Promise<void> {
  1229. // 尝试多种可能的按钮名称
  1230. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', {
  1231. name: /^(确认激活|激活|确定|确认)$/
  1232. });
  1233. await confirmButton.click();
  1234. // 等待确认对话框关闭和网络请求完成
  1235. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  1236. .catch(() => console.debug('激活确认对话框关闭超时'));
  1237. // networkidle 可能因为持续的网络活动而失败,使用更宽松的超时
  1238. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.TABLE_LOAD })
  1239. .catch(() => console.debug('networkidle 超时,继续执行'));
  1240. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1241. }
  1242. /**
  1243. * 激活订单(完整流程)
  1244. * @param orderName 订单名称
  1245. * @returns 是否成功激活
  1246. */
  1247. async activateOrder(orderName: string): Promise<boolean> {
  1248. await this.openActivateDialog(orderName);
  1249. await this.confirmActivate();
  1250. // 等待并检查 Toast 消息
  1251. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1252. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  1253. const hasSuccess = await successToast.count() > 0;
  1254. return hasSuccess;
  1255. }
  1256. /**
  1257. * 打开关闭订单确认对话框
  1258. * @param orderName 订单名称
  1259. */
  1260. async openCloseDialog(orderName: string): Promise<void> {
  1261. // 找到订单行并点击"打开菜单"按钮
  1262. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  1263. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1264. await menuButton.click();
  1265. // 等待菜单出现并点击"关闭"选项
  1266. const closeOption = this.page.getByRole('menuitem', { name: /关闭|关闭订单|完成/ });
  1267. await closeOption.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  1268. await closeOption.click();
  1269. // 等待确认对话框出现
  1270. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  1271. }
  1272. /**
  1273. * 确认关闭订单
  1274. */
  1275. async confirmClose(): Promise<void> {
  1276. // 尝试多种可能的按钮名称
  1277. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', {
  1278. name: /^(确认关闭|关闭|确定|确认)$/
  1279. });
  1280. await confirmButton.click();
  1281. // 等待确认对话框关闭和网络请求完成
  1282. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  1283. .catch(() => console.debug('关闭确认对话框关闭超时'));
  1284. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.TABLE_LOAD });
  1285. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1286. }
  1287. /**
  1288. * 关闭订单(完整流程)
  1289. * @param orderName 订单名称
  1290. * @returns 是否成功关闭
  1291. */
  1292. async closeOrder(orderName: string): Promise<boolean> {
  1293. await this.openCloseDialog(orderName);
  1294. await this.confirmClose();
  1295. // 等待并检查 Toast 消息
  1296. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1297. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  1298. const hasSuccess = await successToast.count() > 0;
  1299. return hasSuccess;
  1300. }
  1301. /**
  1302. * 获取订单的当前状态(从列表页面)
  1303. * @param orderName 订单名称
  1304. * @returns 订单状态值或 null
  1305. */
  1306. async getOrderStatus(orderName: string): Promise<OrderStatus | null> {
  1307. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  1308. // 等待行可见
  1309. await orderRow.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT }).catch(() => {
  1310. console.debug(`订单 "${orderName}" 行不可见`);
  1311. });
  1312. const rowCount = await orderRow.count();
  1313. if (rowCount === 0) {
  1314. console.debug(`订单 "${orderName}" 不存在`);
  1315. return null;
  1316. }
  1317. // 尝试多种策略定位状态列
  1318. // 策略1: 查找包含状态文本的单元格(但排除订单名称列)
  1319. const allCells = orderRow.locator('td');
  1320. const cellCount = await allCells.count();
  1321. for (let i = 1; i < cellCount; i++) { // 跳过第一列(通常是订单名称)
  1322. const cell = allCells.nth(i);
  1323. const cellText = await cell.textContent();
  1324. if (cellText) {
  1325. // 检查是否包含完整的状态标签(避免部分匹配)
  1326. for (const [statusValue, statusLabel] of Object.entries(ORDER_STATUS_LABELS)) {
  1327. // 使用更严格的匹配:必须是状态标签本身或包含完整标签
  1328. const trimmedText = cellText.trim();
  1329. if (trimmedText === statusLabel || trimmedText.includes(`${statusLabel}`)) {
  1330. // 验证不是订单名称列(额外检查)
  1331. const firstCellText = await allCells.nth(0).textContent();
  1332. if (firstCellText && !firstCellText.includes(orderName.substring(0, 3))) {
  1333. // 第一列不包含订单名称开头,说明列结构可能不同
  1334. return statusValue as OrderStatus;
  1335. }
  1336. // 跳过第一列后找到的状态标签才返回
  1337. return statusValue as OrderStatus;
  1338. }
  1339. }
  1340. }
  1341. }
  1342. // 策略2: 如果上述方法失败,尝试查找状态徽章/标签元素
  1343. // 查找具有状态样式特征的元素
  1344. const statusBadge = orderRow.locator('[class*="status"], [class*="badge"], span').filter({
  1345. hasText: Object.values(ORDER_STATUS_LABELS)
  1346. });
  1347. if (await statusBadge.count() > 0) {
  1348. const badgeText = await statusBadge.first().textContent();
  1349. if (badgeText) {
  1350. for (const [statusValue, statusLabel] of Object.entries(ORDER_STATUS_LABELS)) {
  1351. if (badgeText.includes(statusLabel)) {
  1352. return statusValue as OrderStatus;
  1353. }
  1354. }
  1355. }
  1356. }
  1357. console.debug(`无法从订单 "${orderName}" 中解析状态`);
  1358. return null;
  1359. }
  1360. /**
  1361. * 验证订单状态
  1362. * @param orderName 订单名称
  1363. * @param expectedStatus 期望的状态
  1364. */
  1365. async expectOrderStatus(orderName: string, expectedStatus: OrderStatus): Promise<void> {
  1366. const actualStatus = await this.getOrderStatus(orderName);
  1367. if (actualStatus === null) {
  1368. throw new Error(`订单 "${orderName}" 未找到或状态列无法识别`);
  1369. }
  1370. if (actualStatus !== expectedStatus) {
  1371. throw new Error(
  1372. `订单 "${orderName}" 状态不匹配: 期望 "${ORDER_STATUS_LABELS[expectedStatus]}", 实际 "${ORDER_STATUS_LABELS[actualStatus]}"`
  1373. );
  1374. }
  1375. }
  1376. /**
  1377. * 检查激活按钮是否可用
  1378. *
  1379. * **注意**: 此方法会打开和关闭菜单,属于有副作用的操作
  1380. *
  1381. * @param orderName 订单名称
  1382. * @returns 按钮是否可用
  1383. */
  1384. async checkActivateButtonEnabled(orderName: string): Promise<boolean> {
  1385. // 找到订单行并打开菜单
  1386. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  1387. // 检查订单是否存在
  1388. const orderCount = await orderRow.count();
  1389. if (orderCount === 0) {
  1390. console.debug(`订单 "${orderName}" 不存在`);
  1391. return false;
  1392. }
  1393. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1394. try {
  1395. await menuButton.click();
  1396. } catch (error) {
  1397. console.debug(`无法打开订单 "${orderName}" 的菜单:`, error);
  1398. return false;
  1399. }
  1400. // 检查激活菜单项是否可点击
  1401. const activateOption = this.page.getByRole('menuitem', { name: /激活|激活订单/ });
  1402. const isVisible = await activateOption.isVisible().catch(() => false);
  1403. let isEnabled = false;
  1404. if (isVisible) {
  1405. // 检查是否有禁用属性或样式
  1406. const isDisabled = await activateOption.isDisabled().catch(() => false);
  1407. isEnabled = !isDisabled;
  1408. }
  1409. // 关闭菜单以便后续操作
  1410. await this.page.keyboard.press('Escape');
  1411. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  1412. return isEnabled;
  1413. }
  1414. /**
  1415. * 检查关闭按钮是否可用
  1416. *
  1417. * **注意**: 此方法会打开和关闭菜单,属于有副作用的操作
  1418. *
  1419. * @param orderName 订单名称
  1420. * @returns 按钮是否可用
  1421. */
  1422. async checkCloseButtonEnabled(orderName: string): Promise<boolean> {
  1423. // 找到订单行并打开菜单
  1424. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName }).first();
  1425. // 检查订单是否存在
  1426. const orderCount = await orderRow.count();
  1427. if (orderCount === 0) {
  1428. console.debug(`订单 "${orderName}" 不存在`);
  1429. return false;
  1430. }
  1431. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1432. try {
  1433. await menuButton.click();
  1434. } catch (error) {
  1435. console.debug(`无法打开订单 "${orderName}" 的菜单:`, error);
  1436. return false;
  1437. }
  1438. // 检查关闭菜单项是否可点击
  1439. const closeOption = this.page.getByRole('menuitem', { name: /关闭|关闭订单|完成/ });
  1440. const isVisible = await closeOption.isVisible().catch(() => false);
  1441. let isEnabled = false;
  1442. if (isVisible) {
  1443. // 检查是否有禁用属性或样式
  1444. const isDisabled = await closeOption.isDisabled().catch(() => false);
  1445. isEnabled = !isDisabled;
  1446. }
  1447. // 关闭菜单以便后续操作
  1448. await this.page.keyboard.press('Escape');
  1449. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  1450. return isEnabled;
  1451. }
  1452. }