order-management.page.ts 57 KB

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