order-management.page.ts 46 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397
  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 });
  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 });
  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.expectedStartDate) {
  328. const dateInput = this.page.getByLabel(/预计开始日期|开始日期/);
  329. await dateInput.fill(data.expectedStartDate);
  330. }
  331. // 选择平台
  332. if (data.platformName) {
  333. await selectRadixOption(this.page, '平台', data.platformName);
  334. }
  335. // 选择公司
  336. if (data.companyName) {
  337. await selectRadixOption(this.page, '公司', data.companyName);
  338. }
  339. // 选择渠道
  340. if (data.channelName) {
  341. await selectRadixOption(this.page, '渠道', data.channelName);
  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. * 提交表单
  356. * @returns 表单提交结果
  357. */
  358. async submitForm(): Promise<FormSubmitResult> {
  359. // 收集网络响应
  360. const responses: NetworkResponse[] = [];
  361. // 监听所有网络请求
  362. const responseHandler = async (response: Response) => {
  363. const url = response.url();
  364. // 监听订单管理相关的 API 请求
  365. if (url.includes('/orders') || url.includes('order')) {
  366. const requestBody = response.request()?.postData();
  367. const responseBody = await response.text().catch(() => '');
  368. let jsonBody = null;
  369. try {
  370. jsonBody = JSON.parse(responseBody);
  371. } catch {
  372. // 不是 JSON 响应
  373. }
  374. responses.push({
  375. url,
  376. method: response.request()?.method() ?? 'UNKNOWN',
  377. status: response.status(),
  378. ok: response.ok(),
  379. responseHeaders: await response.allHeaders().catch(() => ({})),
  380. responseBody: jsonBody || responseBody,
  381. });
  382. }
  383. };
  384. this.page.on('response', responseHandler);
  385. try {
  386. // 点击提交按钮(创建或更新)
  387. const submitButton = this.page.getByRole('button', { name: /^(创建|更新|保存)$/ });
  388. await submitButton.click();
  389. // 等待网络请求完成(使用较宽松的超时,因为有些操作可能不触发网络请求)
  390. try {
  391. await this.page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.DIALOG });
  392. } catch {
  393. // domcontentloaded 超时不是致命错误,继续检查 Toast 消息
  394. console.debug('domcontentloaded 超时,继续检查 Toast 消息');
  395. }
  396. } finally {
  397. // 确保监听器总是被移除,防止内存泄漏
  398. this.page.off('response', responseHandler);
  399. }
  400. // 等待 Toast 消息显示
  401. await this.page.waitForTimeout(TIMEOUTS.VERY_LONG);
  402. // 检查 Toast 消息
  403. const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
  404. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  405. const hasError = await errorToast.count() > 0;
  406. const hasSuccess = await successToast.count() > 0;
  407. let errorMessage: string | null = null;
  408. let successMessage: string | null = null;
  409. if (hasError) {
  410. errorMessage = await errorToast.first().textContent();
  411. }
  412. if (hasSuccess) {
  413. successMessage = await successToast.first().textContent();
  414. }
  415. return {
  416. success: hasSuccess || (!hasError && !hasSuccess),
  417. hasError,
  418. hasSuccess,
  419. errorMessage: errorMessage ?? undefined,
  420. successMessage: successMessage ?? undefined,
  421. responses,
  422. };
  423. }
  424. /**
  425. * 取消对话框
  426. */
  427. async cancelDialog() {
  428. const cancelButton = this.page.getByRole('button', { name: '取消' });
  429. await cancelButton.click();
  430. await this.waitForDialogClosed();
  431. }
  432. /**
  433. * 等待对话框关闭
  434. */
  435. async waitForDialogClosed() {
  436. // 先等待一段时间让对话框有机会关闭
  437. await this.page.waitForTimeout(TIMEOUTS.LONG);
  438. // 检查是否还有对话框可见
  439. const dialogs = this.page.locator('[role="dialog"]');
  440. const dialogCount = await dialogs.count();
  441. if (dialogCount === 0) {
  442. // 没有对话框了,已经关闭
  443. console.debug('对话框已关闭(无对话框元素)');
  444. return;
  445. }
  446. // 尝试等待对话框隐藏或从 DOM 中移除
  447. try {
  448. await dialogs.first().waitFor({ state: 'hidden', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  449. console.debug('对话框已关闭');
  450. } catch {
  451. // 超时不是致命错误,对话框可能已经以其他方式关闭
  452. console.debug('对话框关闭等待超时,继续执行');
  453. }
  454. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  455. }
  456. /**
  457. * 确认删除操作
  458. */
  459. async confirmDelete() {
  460. // 尝试多种可能的按钮名称
  461. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', {
  462. name: /^(确认删除|删除|确定|确认)$/
  463. });
  464. await confirmButton.click();
  465. // 等待确认对话框关闭和网络请求完成
  466. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  467. .catch(() => console.debug('删除确认对话框关闭超时'));
  468. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.TABLE_LOAD });
  469. await this.page.waitForTimeout(TIMEOUTS.LONG);
  470. }
  471. /**
  472. * 取消删除操作
  473. */
  474. async cancelDelete() {
  475. const cancelButton = this.page.getByRole('button', { name: '取消' }).and(
  476. this.page.locator('[role="alertdialog"]')
  477. );
  478. await cancelButton.click();
  479. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  480. .catch(() => console.debug('删除确认对话框关闭超时(取消操作)'));
  481. }
  482. /**
  483. * 验证订单是否存在
  484. * @param orderName 订单名称
  485. * @returns 订单是否存在
  486. */
  487. async orderExists(orderName: string): Promise<boolean> {
  488. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  489. return (await orderRow.count()) > 0;
  490. }
  491. // ===== 订单详情 =====
  492. /**
  493. * 打开订单详情对话框
  494. * @param orderName 订单名称
  495. */
  496. async openDetailDialog(orderName: string) {
  497. // 找到订单行
  498. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  499. // 先点击操作菜单触发按钮("打开菜单" 或 MoreHorizontal 图标)
  500. const menuTrigger = orderRow.getByRole('button', { name: /打开菜单/ });
  501. await menuTrigger.click();
  502. // 等待菜单显示
  503. await this.page.waitForTimeout(TIMEOUTS.VERY_SHORT);
  504. // 点击"查看详情"菜单项
  505. const detailButton = this.page.getByRole('menuitem', { name: /查看详情/ });
  506. await detailButton.click();
  507. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  508. }
  509. /**
  510. * 获取订单详情对话框中的基本信息
  511. * @returns 订单基本信息
  512. */
  513. async getOrderDetailInfo(): Promise<{
  514. name?: string;
  515. status?: string;
  516. workStatus?: string;
  517. expectedStartDate?: string;
  518. platform?: string;
  519. company?: string;
  520. channel?: string;
  521. }> {
  522. const dialog = this.page.locator('[role="dialog"]');
  523. const result: Record<string, string | undefined> = {};
  524. // 使用 data-testid 直接定位元素(更可靠)
  525. // DOM 结构: <div className="flex items-center justify-between">
  526. // <span className="text-sm font-medium">标签:</span>
  527. // <span data-testid="order-detail-xxx">值</span>
  528. // </div>
  529. // 订单名称 - 使用 data-testid
  530. const nameElement = dialog.locator('[data-testid="order-detail-name"]');
  531. if (await nameElement.count() > 0) {
  532. result.name = (await nameElement.textContent())?.trim();
  533. }
  534. // 订单状态
  535. const statusElement = dialog.locator('[data-testid="order-detail-status"]');
  536. if (await statusElement.count() > 0) {
  537. result.status = (await statusElement.textContent())?.trim();
  538. }
  539. // 工作状态 - 查找包含"工作状态"标签的行
  540. const workStatusRow = dialog.locator('div').filter({ hasText: /工作状态:/ }).first();
  541. if (await workStatusRow.count() > 0) {
  542. const workStatusElement = workStatusRow.locator('span').nth(1);
  543. result.workStatus = (await workStatusElement.textContent())?.trim();
  544. }
  545. // 预计开始日期 - 使用 data-testid
  546. const expectedStartDateElement = dialog.locator('[data-testid="order-detail-expected-start"]');
  547. if (await expectedStartDateElement.count() > 0) {
  548. result.expectedStartDate = (await expectedStartDateElement.textContent())?.trim();
  549. }
  550. // 平台 - 使用 data-testid
  551. const platformElement = dialog.locator('[data-testid="order-detail-platform"]');
  552. if (await platformElement.count() > 0) {
  553. result.platform = (await platformElement.textContent())?.trim();
  554. }
  555. // 公司 - 使用 data-testid
  556. const companyElement = dialog.locator('[data-testid="order-detail-company"]');
  557. if (await companyElement.count() > 0) {
  558. result.company = (await companyElement.textContent())?.trim();
  559. }
  560. // 渠道 - 使用 data-testid
  561. const channelElement = dialog.locator('[data-testid="order-detail-channel"]');
  562. if (await channelElement.count() > 0) {
  563. result.channel = (await channelElement.textContent())?.trim();
  564. }
  565. return result;
  566. }
  567. /**
  568. * 从订单详情对话框中获取关联人员列表
  569. * @returns 人员信息列表
  570. */
  571. async getPersonListFromDetail(): Promise<Array<{
  572. name?: string;
  573. workStatus?: string;
  574. hireDate?: string;
  575. salary?: string;
  576. }>> {
  577. const dialog = this.page.locator('[role="dialog"]');
  578. const result: Array<{ name?: string; workStatus?: string; hireDate?: string; salary?: string }> = [];
  579. // 查找所有表格,对话框中可能有两个表格:
  580. // 1. "待添加人员列表" - 临时表格,包含未确认的人员
  581. // 2. "绑定人员列表" - 实际已绑定到订单的人员
  582. // 我们需要第二个"绑定人员列表"表格
  583. const allTables = dialog.locator('table');
  584. const tableCount = await allTables.count();
  585. // 查找"绑定人员列表"表格(通常是包含"工作状态"列的表格)
  586. let personTable;
  587. for (let i = 0; i < tableCount; i++) {
  588. const table = allTables.nth(i);
  589. const tableText = await table.textContent();
  590. // 绑定人员列表表格包含"工作状态"列,而待添加人员列表没有
  591. if (tableText && tableText.includes('工作状态')) {
  592. personTable = table;
  593. break;
  594. }
  595. }
  596. const personList = dialog.locator('[class*="person"], [class*="employee"], [data-testid*="person"]');
  597. // 优先使用表格形式
  598. if (personTable) {
  599. const rows = personTable.locator('tbody tr');
  600. const rowCount = await rows.count();
  601. for (let i = 0; i < rowCount; i++) {
  602. const row = rows.nth(i);
  603. const cells = row.locator('td');
  604. const cellCount = await cells.count();
  605. const personInfo: { name?: string; workStatus?: string; hireDate?: string; salary?: string } = {};
  606. // 根据列数量和数据类型提取信息
  607. for (let j = 0; j < cellCount; j++) {
  608. const cellText = await cells.nth(j).textContent();
  609. if (!cellText) continue;
  610. const trimmedText = cellText.trim();
  611. // 尝试识别列内容
  612. // 姓名通常在第一列
  613. if (j === 0 && trimmedText) {
  614. personInfo.name = trimmedText;
  615. }
  616. // 工作状态检查
  617. for (const [statusValue, statusLabel] of Object.entries(WORK_STATUS_LABELS)) {
  618. if (trimmedText.includes(statusLabel)) {
  619. personInfo.workStatus = statusLabel;
  620. break;
  621. }
  622. }
  623. // 薪资检查(包含数字)
  624. if (/^\d+(\.\d+)?$/.test(trimmedText.replace(/,/g, ''))) {
  625. personInfo.salary = trimmedText;
  626. }
  627. // 日期检查(符合日期格式)
  628. if (/^\d{4}-\d{2}-\d{2}$/.test(trimmedText) || /^\d{4}\/\d{2}\/\d{2}$/.test(trimmedText)) {
  629. if (!personInfo.hireDate) {
  630. personInfo.hireDate = trimmedText;
  631. }
  632. }
  633. }
  634. if (personInfo.name || personInfo.workStatus) {
  635. result.push(personInfo);
  636. }
  637. }
  638. } else if (await personList.count() > 0) {
  639. // 如果是列表形式而非表格
  640. const listItems = personList.locator('[class*="item"], [class*="row"], li, div');
  641. const itemCount = await listItems.count();
  642. for (let i = 0; i < itemCount; i++) {
  643. const item = listItems.nth(i);
  644. const itemText = await item.textContent();
  645. if (itemText && itemText.trim()) {
  646. result.push({ name: itemText.trim() });
  647. }
  648. }
  649. }
  650. return result;
  651. }
  652. /**
  653. * 从订单详情对话框中获取附件列表
  654. * @returns 附件信息列表
  655. */
  656. async getAttachmentListFromDetail(): Promise<Array<{
  657. fileName?: string;
  658. uploadDate?: string;
  659. uploader?: string;
  660. }>> {
  661. const dialog = this.page.locator('[role="dialog"]');
  662. const result: Array<{ fileName?: string; uploadDate?: string; uploader?: string }> = [];
  663. // 查找附件列表区域
  664. // 尝试多种可能的定位策略
  665. const attachmentTable = dialog.locator('table').filter({ hasText: /附件|文件/ });
  666. const attachmentList = dialog.locator('[class*="attachment"], [class*="file"], [data-testid*="attachment"]');
  667. // 优先使用表格形式
  668. if (await attachmentTable.count() > 0) {
  669. const rows = attachmentTable.locator('tbody tr');
  670. const rowCount = await rows.count();
  671. for (let i = 0; i < rowCount; i++) {
  672. const row = rows.nth(i);
  673. const cells = row.locator('td');
  674. const cellCount = await cells.count();
  675. const attachmentInfo: { fileName?: string; uploadDate?: string; uploader?: string } = {};
  676. for (let j = 0; j < cellCount; j++) {
  677. const cellText = await cells.nth(j).textContent();
  678. if (!cellText) continue;
  679. const trimmedText = cellText.trim();
  680. // 文件名通常在第一列
  681. if (j === 0 && trimmedText) {
  682. attachmentInfo.fileName = trimmedText;
  683. }
  684. // 日期检查
  685. if (/^\d{4}-\d{2}-\d{2}/.test(trimmedText) || /^\d{4}\/\d{2}\/\d{2}/.test(trimmedText)) {
  686. if (!attachmentInfo.uploadDate) {
  687. attachmentInfo.uploadDate = trimmedText;
  688. }
  689. }
  690. // 上传者通常是文本用户名
  691. if (j > 0 && trimmedText && !attachmentInfo.uploader && !attachmentInfo.uploadDate && !/^\d{4}/.test(trimmedText)) {
  692. attachmentInfo.uploader = trimmedText;
  693. }
  694. }
  695. if (attachmentInfo.fileName) {
  696. result.push(attachmentInfo);
  697. }
  698. }
  699. } else if (await attachmentList.count() > 0) {
  700. // 如果是列表形式
  701. const listItems = attachmentList.locator('[class*="item"], [class*="row"], li, div');
  702. const itemCount = await listItems.count();
  703. for (let i = 0; i < itemCount; i++) {
  704. const item = listItems.nth(i);
  705. const itemText = await item.textContent();
  706. if (itemText && itemText.trim()) {
  707. result.push({ fileName: itemText.trim() });
  708. }
  709. }
  710. }
  711. return result;
  712. }
  713. /**
  714. * 关闭订单详情对话框
  715. */
  716. async closeDetailDialog(): Promise<void> {
  717. // 尝试多种关闭方式
  718. // 方式1: 点击右上角 X 按钮
  719. const closeButton = this.page.locator('[role="dialog"]').getByRole('button', { name: '关闭' }).first();
  720. const closeButtonCount = await closeButton.count();
  721. if (closeButtonCount > 0) {
  722. await closeButton.click();
  723. } else {
  724. // 方式2: 点击取消按钮
  725. const cancelButton = this.page.locator('[role="dialog"]').getByRole('button', { name: '取消' }).first();
  726. const cancelButtonCount = await cancelButton.count();
  727. if (cancelButtonCount > 0) {
  728. await cancelButton.click();
  729. } else {
  730. // 方式3: 按 Escape 键
  731. await this.page.keyboard.press('Escape');
  732. }
  733. }
  734. // 等待对话框关闭
  735. await this.waitForDialogClosed();
  736. }
  737. // ===== 人员关联管理 =====
  738. /**
  739. * 打开人员管理对话框
  740. *
  741. * **使用场景:**
  742. * - **从订单列表页打开**: 传入 `orderName` 参数,方法会先找到对应订单行,再点击人员管理按钮
  743. * - **从订单详情页打开**: 不传参数,方法会直接点击页面中的人员管理按钮
  744. *
  745. * @param orderName 订单名称(可选)。从列表页打开时需要传入,从详情页打开时不传
  746. *
  747. * @example
  748. * ```typescript
  749. * // 从订单列表页打开
  750. * await orderPage.openPersonManagementDialog('测试订单');
  751. *
  752. * // 从订单详情页打开
  753. * await orderPage.openDetailDialog('测试订单');
  754. * await orderPage.openPersonManagementDialog();
  755. * ```
  756. */
  757. async openPersonManagementDialog(orderName?: string) {
  758. // 人员管理功能直接集成在订单详情对话框中
  759. // 如果提供了订单名称,打开订单详情对话框
  760. if (orderName) {
  761. await this.openDetailDialog(orderName);
  762. }
  763. // 人员管理功能已在详情对话框中,无需额外操作
  764. }
  765. /**
  766. * 添加人员到订单
  767. * @param personData 人员数据
  768. */
  769. async addPersonToOrder(personData: OrderPersonData) {
  770. // 点击添加人员按钮
  771. const addButton = this.page.getByRole('button', { name: /添加人员|新增人员/ });
  772. await addButton.click();
  773. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  774. // 选择残疾人(支持通过名称选择)
  775. if (personData.disabledPersonName) {
  776. await selectRadixOption(this.page, '残疾人|选择残疾人', personData.disabledPersonName);
  777. } else if (personData.disabledPersonId) {
  778. // 如果只提供了 ID,尝试在对话框中选择第一个残疾人
  779. const firstCheckbox = this.page.locator('[role="dialog"]').locator('table tbody tr').first().locator('input[type="checkbox"]').first();
  780. try {
  781. await firstCheckbox.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  782. await firstCheckbox.check();
  783. } catch {
  784. console.debug('没有可用的残疾人数据');
  785. }
  786. }
  787. // 填写入职日期
  788. if (personData.hireDate) {
  789. const hireDateInput = this.page.getByLabel(/入职日期/);
  790. await hireDateInput.fill(personData.hireDate);
  791. }
  792. // 填写薪资
  793. if (personData.salary !== undefined) {
  794. const salaryInput = this.page.getByLabel(/薪资|工资/);
  795. await salaryInput.fill(String(personData.salary));
  796. }
  797. // 选择工作状态
  798. if (personData.workStatus) {
  799. const workStatusLabel = WORK_STATUS_LABELS[personData.workStatus];
  800. await selectRadixOption(this.page, '工作状态', workStatusLabel);
  801. }
  802. // 提交
  803. const submitButton = this.page.getByRole('button', { name: /^(添加|确定|保存)$/ });
  804. await submitButton.click();
  805. await this.page.waitForLoadState('networkidle');
  806. await this.page.waitForTimeout(TIMEOUTS.LONG);
  807. }
  808. /**
  809. * 修改人员工作状态
  810. * @param personName 人员姓名
  811. * @param newStatus 新的工作状态
  812. */
  813. async updatePersonWorkStatus(personName: string, newStatus: WorkStatus) {
  814. const dialog = this.page.locator('[role="dialog"]');
  815. // 等待对话框完全加载
  816. await dialog.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  817. // 从 error-context.md 可知:
  818. // 1. 对话框中有"绑定人员列表"表格
  819. // 2. 表格列:ID 姓名 性别 残疾类型 联系电话 入职日期 离职日期 工作状态 薪资
  820. // 3. 工作状态列直接是 combobox,不需要点击编辑按钮
  821. // 查找所有表格
  822. const allTables = dialog.locator('table');
  823. const allTableCount = await allTables.count();
  824. console.debug(`对话框中总共有 ${allTableCount} 个表格`);
  825. let personTable = allTables.first();
  826. // 找到包含"绑定人员"或"工作状态"列的表格(第二个表格是绑定人员列表)
  827. for (let i = 0; i < allTableCount; i++) {
  828. const table = allTables.nth(i);
  829. const tableText = await table.textContent();
  830. if (tableText && (tableText.includes('绑定人员') || tableText.includes('工作状态'))) {
  831. personTable = table;
  832. console.debug(`找到人员表格(索引 ${i})`);
  833. break;
  834. }
  835. }
  836. // 在表格中查找包含指定人员名称的行
  837. const targetRow = personTable.locator('tbody tr').filter({ hasText: personName }).first();
  838. const rowCount = await targetRow.count();
  839. console.debug(`找到 ${rowCount} 个匹配的人员行`);
  840. if (rowCount === 0) {
  841. throw new Error(`未找到人员 ${personName}`);
  842. }
  843. // 等待行可见
  844. await targetRow.waitFor({ state: 'visible', timeout: TIMEOUTS.DIALOG });
  845. // 从 error-context.md 可知,工作状态在单元格中是一个 combobox
  846. // 表格列:ID 姓名 性别 残疾类型 联系电话 入职日期 离职日期 工作状态 薪资
  847. // 工作状态是倒数第二列(薪资是最后一列)
  848. const cells = targetRow.locator('td');
  849. const cellCount = await cells.count();
  850. console.debug(`人员行有 ${cellCount} 个单元格`);
  851. // 工作状态在倒数第二列
  852. const workStatusCell = cells.nth(cellCount - 2);
  853. const workStatusCombobox = workStatusCell.getByRole('combobox');
  854. const comboboxCount = await workStatusCombobox.count();
  855. console.debug(`工作状态 combobox 数量: ${comboboxCount}`);
  856. if (comboboxCount === 0) {
  857. throw new Error(`未找到人员 ${personName} 的工作状态选择器`);
  858. }
  859. await workStatusCombobox.click({ timeout: TIMEOUTS.DIALOG });
  860. console.debug('工作状态 combobox 已点击');
  861. // 等待下拉选项显示
  862. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  863. // 使用中文标签选择选项
  864. // 注意:UI 中的工作状态选项与 WORK_STATUS_LABELS 不同
  865. // UI 选项:未入职、已入职、工作中、已离职
  866. // WORK_STATUS_LABELS:未就业、待就业、已就业、已离职
  867. const statusMapping: Record<WorkStatus, string> = {
  868. not_working: '未入职',
  869. pre_working: '已入职',
  870. working: '工作中',
  871. resigned: '已离职',
  872. };
  873. const newWorkStatusLabel = statusMapping[newStatus];
  874. console.debug(`尝试选择状态: ${newWorkStatusLabel}`);
  875. const optionLocator = this.page.getByRole('option', { name: newWorkStatusLabel });
  876. const optionCount = await optionLocator.count();
  877. console.debug(`找到 ${optionCount} 个选项`);
  878. if (optionCount === 0) {
  879. throw new Error(`未找到工作状态选项: ${newWorkStatusLabel}`);
  880. }
  881. await optionLocator.first().click({ timeout: TIMEOUTS.DIALOG });
  882. console.debug(`工作状态已更新为: ${newWorkStatusLabel}`);
  883. // 使用较短的超时时间等待网络空闲
  884. await this.page.waitForLoadState('domcontentloaded', { timeout: TIMEOUTS.DIALOG })
  885. .catch(() => console.debug('domcontentloaded 等待超时,继续'));
  886. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  887. }
  888. // ===== 附件管理 =====
  889. /**
  890. * 打开添加附件对话框
  891. */
  892. async openAddAttachmentDialog() {
  893. const attachmentButton = this.page.getByRole('button', { name: /添加附件|上传附件/ });
  894. await attachmentButton.click();
  895. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  896. }
  897. /**
  898. * 上传附件
  899. * @param personName 人员姓名
  900. * @param fileName 文件名
  901. * @param mimeType 文件类型(默认为 image/jpeg)
  902. */
  903. async uploadAttachment(personName: string, fileName: string, mimeType: string = 'image/jpeg') {
  904. // 选择订单人员
  905. const personSelect = this.page.getByLabel(/选择人员|订单人员/);
  906. await personSelect.click();
  907. await this.page.getByRole('option', { name: personName }).click();
  908. // 查找文件上传输入框
  909. const fileInput = this.page.locator('input[type="file"]');
  910. await fileInput.setInputFiles({
  911. name: fileName,
  912. mimeType,
  913. buffer: Buffer.from(`fake ${fileName} content`),
  914. });
  915. // 等待上传处理
  916. await this.page.waitForTimeout(TIMEOUTS.MEDIUM);
  917. // 提交
  918. const submitButton = this.page.getByRole('button', { name: /^(上传|确定|保存)$/ });
  919. await submitButton.click();
  920. await this.page.waitForLoadState('networkidle');
  921. await this.page.waitForTimeout(TIMEOUTS.LONG);
  922. }
  923. // ===== 高级操作 =====
  924. /**
  925. * 创建订单(完整流程)
  926. * @param data 订单数据
  927. * @returns 表单提交结果
  928. */
  929. async createOrder(data: OrderData): Promise<FormSubmitResult> {
  930. await this.openCreateDialog();
  931. await this.fillOrderForm(data);
  932. const result = await this.submitForm();
  933. await this.waitForDialogClosed();
  934. return result;
  935. }
  936. /**
  937. * 编辑订单(完整流程)
  938. * @param orderName 订单名称
  939. * @param data 更新的订单数据
  940. * @returns 表单提交结果
  941. */
  942. async editOrder(orderName: string, data: OrderData): Promise<FormSubmitResult> {
  943. await this.openEditDialog(orderName);
  944. await this.fillOrderForm(data);
  945. const result = await this.submitForm();
  946. await this.waitForDialogClosed();
  947. return result;
  948. }
  949. /**
  950. * 删除订单(完整流程)
  951. * @param orderName 订单名称
  952. * @returns 是否成功删除
  953. */
  954. async deleteOrder(orderName: string): Promise<boolean> {
  955. await this.openDeleteDialog(orderName);
  956. await this.confirmDelete();
  957. // 等待并检查 Toast 消息
  958. await this.page.waitForTimeout(TIMEOUTS.LONG);
  959. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  960. const hasSuccess = await successToast.count() > 0;
  961. return hasSuccess;
  962. }
  963. // ===== 订单状态流转操作 =====
  964. /**
  965. * 打开激活订单确认对话框
  966. * @param orderName 订单名称
  967. */
  968. async openActivateDialog(orderName: string): Promise<void> {
  969. // 找到订单行并点击"打开菜单"按钮(与编辑/删除操作相同的模式)
  970. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  971. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  972. await menuButton.click();
  973. // 等待菜单出现并点击"激活"选项
  974. const activateOption = this.page.getByRole('menuitem', { name: /激活|激活订单/ });
  975. await activateOption.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  976. await activateOption.click();
  977. // 等待确认对话框出现
  978. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  979. }
  980. /**
  981. * 确认激活订单
  982. */
  983. async confirmActivate(): Promise<void> {
  984. // 尝试多种可能的按钮名称
  985. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', {
  986. name: /^(确认激活|激活|确定|确认)$/
  987. });
  988. await confirmButton.click();
  989. // 等待确认对话框关闭和网络请求完成
  990. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  991. .catch(() => console.debug('激活确认对话框关闭超时'));
  992. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.TABLE_LOAD });
  993. await this.page.waitForTimeout(TIMEOUTS.LONG);
  994. }
  995. /**
  996. * 激活订单(完整流程)
  997. * @param orderName 订单名称
  998. * @returns 是否成功激活
  999. */
  1000. async activateOrder(orderName: string): Promise<boolean> {
  1001. await this.openActivateDialog(orderName);
  1002. await this.confirmActivate();
  1003. // 等待并检查 Toast 消息
  1004. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1005. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  1006. const hasSuccess = await successToast.count() > 0;
  1007. return hasSuccess;
  1008. }
  1009. /**
  1010. * 打开关闭订单确认对话框
  1011. * @param orderName 订单名称
  1012. */
  1013. async openCloseDialog(orderName: string): Promise<void> {
  1014. // 找到订单行并点击"打开菜单"按钮
  1015. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  1016. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1017. await menuButton.click();
  1018. // 等待菜单出现并点击"关闭"选项
  1019. const closeOption = this.page.getByRole('menuitem', { name: /关闭|关闭订单|完成/ });
  1020. await closeOption.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT });
  1021. await closeOption.click();
  1022. // 等待确认对话框出现
  1023. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: TIMEOUTS.DIALOG });
  1024. }
  1025. /**
  1026. * 确认关闭订单
  1027. */
  1028. async confirmClose(): Promise<void> {
  1029. // 尝试多种可能的按钮名称
  1030. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', {
  1031. name: /^(确认关闭|关闭|确定|确认)$/
  1032. });
  1033. await confirmButton.click();
  1034. // 等待确认对话框关闭和网络请求完成
  1035. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: TIMEOUTS.DIALOG })
  1036. .catch(() => console.debug('关闭确认对话框关闭超时'));
  1037. await this.page.waitForLoadState('networkidle', { timeout: TIMEOUTS.TABLE_LOAD });
  1038. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1039. }
  1040. /**
  1041. * 关闭订单(完整流程)
  1042. * @param orderName 订单名称
  1043. * @returns 是否成功关闭
  1044. */
  1045. async closeOrder(orderName: string): Promise<boolean> {
  1046. await this.openCloseDialog(orderName);
  1047. await this.confirmClose();
  1048. // 等待并检查 Toast 消息
  1049. await this.page.waitForTimeout(TIMEOUTS.LONG);
  1050. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  1051. const hasSuccess = await successToast.count() > 0;
  1052. return hasSuccess;
  1053. }
  1054. /**
  1055. * 获取订单的当前状态(从列表页面)
  1056. * @param orderName 订单名称
  1057. * @returns 订单状态值或 null
  1058. */
  1059. async getOrderStatus(orderName: string): Promise<OrderStatus | null> {
  1060. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  1061. // 等待行可见
  1062. await orderRow.waitFor({ state: 'visible', timeout: TIMEOUTS.ELEMENT_VISIBLE_SHORT }).catch(() => {
  1063. console.debug(`订单 "${orderName}" 行不可见`);
  1064. });
  1065. const rowCount = await orderRow.count();
  1066. if (rowCount === 0) {
  1067. console.debug(`订单 "${orderName}" 不存在`);
  1068. return null;
  1069. }
  1070. // 尝试多种策略定位状态列
  1071. // 策略1: 查找包含状态文本的单元格(但排除订单名称列)
  1072. const allCells = orderRow.locator('td');
  1073. const cellCount = await allCells.count();
  1074. for (let i = 1; i < cellCount; i++) { // 跳过第一列(通常是订单名称)
  1075. const cell = allCells.nth(i);
  1076. const cellText = await cell.textContent();
  1077. if (cellText) {
  1078. // 检查是否包含完整的状态标签(避免部分匹配)
  1079. for (const [statusValue, statusLabel] of Object.entries(ORDER_STATUS_LABELS)) {
  1080. // 使用更严格的匹配:必须是状态标签本身或包含完整标签
  1081. const trimmedText = cellText.trim();
  1082. if (trimmedText === statusLabel || trimmedText.includes(`${statusLabel}`)) {
  1083. // 验证不是订单名称列(额外检查)
  1084. const firstCellText = await allCells.nth(0).textContent();
  1085. if (firstCellText && !firstCellText.includes(orderName.substring(0, 3))) {
  1086. // 第一列不包含订单名称开头,说明列结构可能不同
  1087. return statusValue as OrderStatus;
  1088. }
  1089. // 跳过第一列后找到的状态标签才返回
  1090. return statusValue as OrderStatus;
  1091. }
  1092. }
  1093. }
  1094. }
  1095. // 策略2: 如果上述方法失败,尝试查找状态徽章/标签元素
  1096. // 查找具有状态样式特征的元素
  1097. const statusBadge = orderRow.locator('[class*="status"], [class*="badge"], span').filter({
  1098. hasText: Object.values(ORDER_STATUS_LABELS)
  1099. });
  1100. if (await statusBadge.count() > 0) {
  1101. const badgeText = await statusBadge.first().textContent();
  1102. if (badgeText) {
  1103. for (const [statusValue, statusLabel] of Object.entries(ORDER_STATUS_LABELS)) {
  1104. if (badgeText.includes(statusLabel)) {
  1105. return statusValue as OrderStatus;
  1106. }
  1107. }
  1108. }
  1109. }
  1110. console.debug(`无法从订单 "${orderName}" 中解析状态`);
  1111. return null;
  1112. }
  1113. /**
  1114. * 验证订单状态
  1115. * @param orderName 订单名称
  1116. * @param expectedStatus 期望的状态
  1117. */
  1118. async expectOrderStatus(orderName: string, expectedStatus: OrderStatus): Promise<void> {
  1119. const actualStatus = await this.getOrderStatus(orderName);
  1120. if (actualStatus === null) {
  1121. throw new Error(`订单 "${orderName}" 未找到或状态列无法识别`);
  1122. }
  1123. if (actualStatus !== expectedStatus) {
  1124. throw new Error(
  1125. `订单 "${orderName}" 状态不匹配: 期望 "${ORDER_STATUS_LABELS[expectedStatus]}", 实际 "${ORDER_STATUS_LABELS[actualStatus]}"`
  1126. );
  1127. }
  1128. }
  1129. /**
  1130. * 检查激活按钮是否可用
  1131. *
  1132. * **注意**: 此方法会打开和关闭菜单,属于有副作用的操作
  1133. *
  1134. * @param orderName 订单名称
  1135. * @returns 按钮是否可用
  1136. */
  1137. async checkActivateButtonEnabled(orderName: string): Promise<boolean> {
  1138. // 找到订单行并打开菜单
  1139. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  1140. // 检查订单是否存在
  1141. const orderCount = await orderRow.count();
  1142. if (orderCount === 0) {
  1143. console.debug(`订单 "${orderName}" 不存在`);
  1144. return false;
  1145. }
  1146. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1147. try {
  1148. await menuButton.click();
  1149. } catch (error) {
  1150. console.debug(`无法打开订单 "${orderName}" 的菜单:`, error);
  1151. return false;
  1152. }
  1153. // 检查激活菜单项是否可点击
  1154. const activateOption = this.page.getByRole('menuitem', { name: /激活|激活订单/ });
  1155. const isVisible = await activateOption.isVisible().catch(() => false);
  1156. let isEnabled = false;
  1157. if (isVisible) {
  1158. // 检查是否有禁用属性或样式
  1159. const isDisabled = await activateOption.isDisabled().catch(() => false);
  1160. isEnabled = !isDisabled;
  1161. }
  1162. // 关闭菜单以便后续操作
  1163. await this.page.keyboard.press('Escape');
  1164. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  1165. return isEnabled;
  1166. }
  1167. /**
  1168. * 检查关闭按钮是否可用
  1169. *
  1170. * **注意**: 此方法会打开和关闭菜单,属于有副作用的操作
  1171. *
  1172. * @param orderName 订单名称
  1173. * @returns 按钮是否可用
  1174. */
  1175. async checkCloseButtonEnabled(orderName: string): Promise<boolean> {
  1176. // 找到订单行并打开菜单
  1177. const orderRow = this.orderTable.locator('tbody tr').filter({ hasText: orderName });
  1178. // 检查订单是否存在
  1179. const orderCount = await orderRow.count();
  1180. if (orderCount === 0) {
  1181. console.debug(`订单 "${orderName}" 不存在`);
  1182. return false;
  1183. }
  1184. const menuButton = orderRow.getByRole('button', { name: '打开菜单' });
  1185. try {
  1186. await menuButton.click();
  1187. } catch (error) {
  1188. console.debug(`无法打开订单 "${orderName}" 的菜单:`, error);
  1189. return false;
  1190. }
  1191. // 检查关闭菜单项是否可点击
  1192. const closeOption = this.page.getByRole('menuitem', { name: /关闭|关闭订单|完成/ });
  1193. const isVisible = await closeOption.isVisible().catch(() => false);
  1194. let isEnabled = false;
  1195. if (isVisible) {
  1196. // 检查是否有禁用属性或样式
  1197. const isDisabled = await closeOption.isDisabled().catch(() => false);
  1198. isEnabled = !isDisabled;
  1199. }
  1200. // 关闭菜单以便后续操作
  1201. await this.page.keyboard.press('Escape');
  1202. await this.page.waitForTimeout(TIMEOUTS.SHORT);
  1203. return isEnabled;
  1204. }
  1205. }