region-management.page.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798
  1. import { Page, Locator } from '@playwright/test';
  2. /**
  3. * 区域层级常量
  4. */
  5. export const REGION_LEVEL = {
  6. PROVINCE: 1,
  7. CITY: 2,
  8. DISTRICT: 3,
  9. } as const;
  10. /**
  11. * 区域层级类型
  12. */
  13. export type RegionLevel = typeof REGION_LEVEL[keyof typeof REGION_LEVEL];
  14. /**
  15. * 区域状态常量
  16. */
  17. export const REGION_STATUS = {
  18. ENABLED: 0,
  19. DISABLED: 1,
  20. } as const;
  21. /**
  22. * 区域数据接口
  23. */
  24. export interface RegionData {
  25. /** 区域名称 */
  26. name: string;
  27. /** 行政区划代码 */
  28. code?: string;
  29. /** 区域层级(1=省, 2=市, 3=区) */
  30. level?: RegionLevel;
  31. /** 父级区域ID */
  32. parentId?: number | null;
  33. /** 状态(0=启用, 1=禁用) */
  34. isDisabled?: typeof REGION_STATUS[keyof typeof REGION_STATUS];
  35. }
  36. /**
  37. * 网络响应数据
  38. */
  39. export interface NetworkResponse {
  40. url: string;
  41. method: string;
  42. status: number;
  43. ok: boolean;
  44. responseHeaders: Record<string, string>;
  45. responseBody: unknown;
  46. }
  47. /**
  48. * 表单提交结果
  49. */
  50. export interface FormSubmitResult {
  51. /** 提交是否成功 */
  52. success: boolean;
  53. /** 是否有错误 */
  54. hasError: boolean;
  55. /** 是否有成功消息 */
  56. hasSuccess: boolean;
  57. /** 错误消息 */
  58. errorMessage?: string;
  59. /** 成功消息 */
  60. successMessage?: string;
  61. /** 网络响应列表 */
  62. responses?: NetworkResponse[];
  63. }
  64. /**
  65. * 区域管理 Page Object
  66. *
  67. * 用于管理省市区树形结构的 E2E 测试
  68. * 页面路径: /admin/areas
  69. *
  70. * @example
  71. * ```typescript
  72. * const regionPage = new RegionManagementPage(page);
  73. * await regionPage.goto();
  74. * await regionPage.createProvince({ name: '测试省', code: '110000' });
  75. * ```
  76. */
  77. export class RegionManagementPage {
  78. readonly page: Page;
  79. // 页面级选择器
  80. readonly pageTitle: Locator;
  81. readonly addProvinceButton: Locator;
  82. readonly treeContainer: Locator;
  83. constructor(page: Page) {
  84. this.page = page;
  85. // 使用精确文本匹配获取页面标题
  86. this.pageTitle = page.getByText('省市区树形管理', { exact: true });
  87. // 使用 role + name 组合获取新增按钮(比单独 text 更健壮)
  88. this.addProvinceButton = page.getByRole('button', { name: '新增省', exact: true });
  89. // 使用 Card 组件的结构来定位树形容器(比 Tailwind 类更健壮)
  90. // 根据实际 DOM: Card > CardContent > AreaTreeAsync > div.border.rounded-lg.bg-background
  91. this.treeContainer = page.locator('.border.rounded-lg').first();
  92. }
  93. /**
  94. * 导航到区域管理页面
  95. */
  96. async goto() {
  97. await this.page.goto('/admin/areas');
  98. await this.page.waitForLoadState('domcontentloaded');
  99. // 等待页面标题出现
  100. await this.pageTitle.waitFor({ state: 'visible', timeout: 15000 });
  101. // 等待树形结构加载
  102. await this.treeContainer.waitFor({ state: 'visible', timeout: 20000 });
  103. await this.expectToBeVisible();
  104. }
  105. /**
  106. * 验证页面关键元素可见
  107. */
  108. async expectToBeVisible() {
  109. await this.pageTitle.waitFor({ state: 'visible', timeout: 15000 });
  110. await this.addProvinceButton.waitFor({ state: 'visible', timeout: 10000 });
  111. }
  112. /**
  113. * 打开新增省对话框
  114. */
  115. async openCreateProvinceDialog() {
  116. await this.addProvinceButton.click();
  117. // 等待对话框出现
  118. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
  119. }
  120. /**
  121. * 打开新增子区域对话框
  122. * @param parentName 父级区域名称
  123. * @param childType 子区域类型('市' 或 '区')
  124. */
  125. async openAddChildDialog(parentName: string, childType: '市' | '区') {
  126. // 首先确保父级节点可见
  127. const parentText = this.treeContainer.getByText(parentName);
  128. await parentText.waitFor({ state: 'visible', timeout: 5000 });
  129. // 找到父级节点并悬停,使操作按钮可见
  130. const regionRow = parentText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  131. await regionRow.hover();
  132. // 找到对应的"新增市"或"新增区"按钮
  133. const buttonName = childType === '市' ? '新增市' : '新增区';
  134. const button = regionRow.getByRole('button', { name: buttonName });
  135. // 等待按钮可见并可点击
  136. await button.waitFor({ state: 'visible', timeout: 3000 });
  137. await button.click({ timeout: 5000 });
  138. // 等待对话框出现
  139. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
  140. }
  141. /**
  142. * 打开编辑区域对话框
  143. * @param regionName 区域名称
  144. */
  145. async openEditDialog(regionName: string) {
  146. await this.waitForTreeLoaded();
  147. // 先尝试直接查找区域
  148. const allRegions = this.treeContainer.getByText(regionName, { exact: true });
  149. let count = await allRegions.count();
  150. // 如果找不到,可能区域在折叠的父节点下
  151. // 对于市级或区级区域,需要展开对应的省级节点
  152. if (count === 0 && (regionName.includes('市') || regionName.includes('区'))) {
  153. console.debug(`区域 "${regionName}" 未找到,尝试展开所有可能的省级节点`);
  154. // 查找所有省级节点(以"省"结尾的区域名称)
  155. const provinceTexts = this.treeContainer.getByText(/省$/);
  156. const provinceCount = await provinceTexts.count();
  157. console.debug(`找到 ${provinceCount} 个省级节点`);
  158. // 展开所有省级节点(不提前退出)
  159. for (let i = 0; i < provinceCount; i++) {
  160. try {
  161. const provinceName = await provinceTexts.nth(i).textContent();
  162. if (provinceName) {
  163. const trimmedName = provinceName.trim();
  164. console.debug(`尝试展开省节点: ${trimmedName}`);
  165. await this.expandNode(trimmedName);
  166. await this.page.waitForTimeout(300);
  167. }
  168. } catch (error) {
  169. // 忽略展开失败,继续下一个
  170. }
  171. }
  172. // 再次检查目标区域
  173. count = await allRegions.count();
  174. console.debug(`展开所有省节点后,目标区域 "${regionName}" 数量: ${count}`);
  175. }
  176. if (count === 0) {
  177. throw new Error(`区域 "${regionName}" 未找到,即使展开所有省级节点后`);
  178. }
  179. // 找到目标区域(如果有多个,使用最后一个)
  180. const targetIndex = count - 1;
  181. const regionText = allRegions.nth(targetIndex >= 0 ? targetIndex : 0);
  182. await regionText.scrollIntoViewIfNeeded();
  183. await this.page.waitForTimeout(500);
  184. const regionRow = regionText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  185. await regionRow.hover();
  186. await this.page.waitForTimeout(300);
  187. const button = regionRow.getByRole('button', { name: '编辑' });
  188. await button.waitFor({ state: 'visible', timeout: 3000 });
  189. await button.click({ timeout: 5000 });
  190. await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
  191. }
  192. /**
  193. * 打开删除确认对话框
  194. * @param regionName 区域名称
  195. */
  196. async openDeleteDialog(regionName: string) {
  197. await this.waitForTreeLoaded();
  198. // 先尝试直接查找区域
  199. const allRegions = this.treeContainer.getByText(regionName, { exact: true });
  200. let count = await allRegions.count();
  201. // 如果找不到,可能区域在折叠的父节点下
  202. // 对于市级或区级区域,需要展开对应的省级节点
  203. if (count === 0 && (regionName.includes('市') || regionName.includes('区') || regionName.includes('街道'))) {
  204. console.debug(`区域 "${regionName}" 未找到,尝试展开所有可能的省级节点`);
  205. // 查找所有省级节点(以"省"结尾的区域名称)
  206. const provinceTexts = this.treeContainer.getByText(/省$/);
  207. const provinceCount = await provinceTexts.count();
  208. console.debug(`找到 ${provinceCount} 个省级节点`);
  209. // 展开所有省级节点(不提前退出)
  210. for (let i = 0; i < provinceCount; i++) {
  211. try {
  212. const provinceName = await provinceTexts.nth(i).textContent();
  213. if (provinceName) {
  214. const trimmedName = provinceName.trim();
  215. console.debug(`尝试展开省节点: ${trimmedName}`);
  216. await this.expandNode(trimmedName);
  217. await this.page.waitForTimeout(300);
  218. }
  219. } catch (error) {
  220. // 忽略展开失败,继续下一个
  221. }
  222. }
  223. // 再次检查目标区域
  224. count = await allRegions.count();
  225. console.debug(`展开所有省节点后,目标区域 "${regionName}" 数量: ${count}`);
  226. }
  227. // 如果还是找不到,尝试展开市级节点(用于区级和街道)
  228. if (count === 0 && (regionName.includes('区') || regionName.includes('街道'))) {
  229. console.debug(`区域 "${regionName}" 仍未找到,尝试展开所有市级节点`);
  230. const cityTexts = this.treeContainer.getByText(/市$/);
  231. const cityCount = await cityTexts.count();
  232. console.debug(`找到 ${cityCount} 个市级节点`);
  233. for (let i = 0; i < cityCount; i++) {
  234. try {
  235. const cityName = await cityTexts.nth(i).textContent();
  236. if (cityName) {
  237. const trimmedName = cityName.trim();
  238. console.debug(`尝试展开市节点: ${trimmedName}`);
  239. await this.expandNode(trimmedName);
  240. await this.page.waitForTimeout(300);
  241. }
  242. } catch (error) {
  243. // 忽略展开失败,继续下一个
  244. }
  245. }
  246. count = await allRegions.count();
  247. console.debug(`展开所有市节点后,目标区域 "${regionName}" 数量: ${count}`);
  248. }
  249. if (count === 0) {
  250. throw new Error(`区域 "${regionName}" 未找到,即使展开所有节点后`);
  251. }
  252. // 找到目标区域(如果有多个,使用最后一个)
  253. const targetIndex = count - 1;
  254. const regionText = allRegions.nth(targetIndex >= 0 ? targetIndex : 0);
  255. await regionText.scrollIntoViewIfNeeded();
  256. await this.page.waitForTimeout(500);
  257. const regionRow = regionText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  258. await regionRow.hover();
  259. await this.page.waitForTimeout(300);
  260. const button = regionRow.getByRole('button', { name: '删除' });
  261. await button.waitFor({ state: 'visible', timeout: 3000 });
  262. await button.click({ timeout: 5000 });
  263. // 等待删除确认对话框出现
  264. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
  265. }
  266. /**
  267. * 打开状态切换确认对话框
  268. * @param regionName 区域名称
  269. */
  270. async openToggleStatusDialog(regionName: string) {
  271. // 等待树形结构加载完成
  272. await this.waitForTreeLoaded();
  273. // 找到所有匹配的区域文本,使用最后一个(最新创建的)
  274. const allRegions = this.treeContainer.getByText(regionName, { exact: true });
  275. const count = await allRegions.count();
  276. if (count === 0) {
  277. throw new Error(`区域 "${regionName}" 未找到`);
  278. }
  279. // 使用最后一个匹配项(最新创建的)
  280. const targetIndex = count - 1;
  281. const regionText = allRegions.nth(targetIndex);
  282. await regionText.waitFor({ state: 'visible', timeout: 5000 });
  283. // 滚动到元素位置
  284. await regionText.scrollIntoViewIfNeeded();
  285. await this.page.waitForTimeout(300);
  286. const regionRow = regionText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  287. await regionRow.hover();
  288. await this.page.waitForTimeout(300);
  289. // 在区域行内查找"启用"或"禁用"按钮(操作按钮组中的状态切换按钮)
  290. const statusButton = regionRow.getByRole('button', { name: /^(启用|禁用)$/ });
  291. await statusButton.waitFor({ state: 'visible', timeout: 3000 });
  292. await statusButton.click({ timeout: 5000 });
  293. // 等待状态切换确认对话框出现
  294. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
  295. }
  296. /**
  297. * 填写区域表单
  298. * @param data 区域数据
  299. */
  300. async fillRegionForm(data: RegionData) {
  301. // 等待表单出现
  302. await this.page.waitForSelector('form', { state: 'visible', timeout: 5000 });
  303. // 填写区域名称
  304. if (data.name) {
  305. await this.page.getByLabel('区域名称').fill(data.name);
  306. }
  307. // 填写行政区划代码
  308. if (data.code) {
  309. await this.page.getByLabel('行政区划代码').fill(data.code);
  310. }
  311. }
  312. /**
  313. * 提交表单
  314. * @returns 表单提交结果
  315. */
  316. async submitForm(): Promise<FormSubmitResult> {
  317. // 收集网络响应
  318. const responses: NetworkResponse[] = [];
  319. // 监听所有网络请求
  320. const responseHandler = async (response: Response) => {
  321. const url = response.url();
  322. // 监听区域管理相关的 API 请求
  323. if (url.includes('/areas') || url.includes('area')) {
  324. const requestBody = response.request()?.postData();
  325. const responseBody = await response.text().catch(() => '');
  326. let jsonBody = null;
  327. try {
  328. jsonBody = JSON.parse(responseBody);
  329. } catch {
  330. // 不是 JSON 响应
  331. }
  332. responses.push({
  333. url,
  334. method: response.request()?.method() ?? 'UNKNOWN',
  335. status: response.status(),
  336. ok: response.ok(),
  337. responseHeaders: await response.allHeaders().catch(() => ({})),
  338. responseBody: jsonBody || responseBody,
  339. });
  340. }
  341. };
  342. this.page.on('response', responseHandler);
  343. // 点击提交按钮(创建或更新)
  344. const submitButton = this.page.getByRole('button', { name: /^(创建|更新)$/ });
  345. await submitButton.click();
  346. // 等待网络请求完成 - 使用更宽松的策略
  347. // networkidle 可能因后台轮询而失败,使用 domcontentloaded 代替
  348. try {
  349. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  350. } catch {
  351. // domcontentloaded 也可能失败,继续执行
  352. }
  353. // 额外等待,给 API 响应一些时间
  354. await this.page.waitForTimeout(1000);
  355. // 移除监听器
  356. this.page.off('response', responseHandler);
  357. // 等待对话框关闭或错误出现
  358. await this.page.waitForTimeout(1500);
  359. // 检查 Toast 消息
  360. const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
  361. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  362. const hasError = await errorToast.count() > 0;
  363. const hasSuccess = await successToast.count() > 0;
  364. let errorMessage: string | null = null;
  365. let successMessage: string | null = null;
  366. if (hasError) {
  367. errorMessage = await errorToast.first().textContent();
  368. }
  369. if (hasSuccess) {
  370. successMessage = await successToast.first().textContent();
  371. }
  372. return {
  373. success: hasSuccess || (!hasError && !hasSuccess),
  374. hasError,
  375. hasSuccess,
  376. errorMessage: errorMessage ?? undefined,
  377. successMessage: successMessage ?? undefined,
  378. responses,
  379. };
  380. }
  381. /**
  382. * 取消对话框
  383. */
  384. async cancelDialog() {
  385. const cancelButton = this.page.getByRole('button', { name: '取消' });
  386. await cancelButton.click();
  387. await this.waitForDialogClosed();
  388. }
  389. /**
  390. * 等待对话框关闭
  391. */
  392. async waitForDialogClosed() {
  393. const dialog = this.page.locator('[role="dialog"]');
  394. await dialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
  395. await this.page.waitForTimeout(500);
  396. }
  397. /**
  398. * 确认删除操作
  399. */
  400. async confirmDelete() {
  401. const confirmButton = this.page.getByRole('button', { name: /^确认删除$/ });
  402. await confirmButton.click();
  403. // 等待确认对话框关闭和网络请求完成
  404. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  405. // 使用更宽松的等待策略
  406. try {
  407. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  408. } catch {
  409. // 继续执行
  410. }
  411. await this.page.waitForTimeout(1000);
  412. }
  413. /**
  414. * 取消删除操作
  415. */
  416. async cancelDelete() {
  417. const cancelButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '取消' });
  418. await cancelButton.click();
  419. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  420. }
  421. /**
  422. * 确认状态切换操作
  423. */
  424. async confirmToggleStatus() {
  425. // 监听 API 响应
  426. let apiResponse: any = null;
  427. const responseHandler = async (response: Response) => {
  428. const url = response.url();
  429. if (url.includes('/areas') || url.includes('area')) {
  430. try {
  431. const responseBody = await response.text();
  432. apiResponse = {
  433. url,
  434. status: response.status(),
  435. body: responseBody,
  436. };
  437. console.debug(`API 响应: ${url}, status=${response.status()}`);
  438. } catch {
  439. // ignore
  440. }
  441. }
  442. };
  443. this.page.on('response', responseHandler);
  444. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '确认' });
  445. await confirmButton.click();
  446. // 等待 API 响应
  447. await this.page.waitForTimeout(2000);
  448. this.page.off('response', responseHandler);
  449. if (apiResponse) {
  450. console.debug(`状态切换 API 响应: status=${apiResponse.status}, body=${apiResponse.body}`);
  451. }
  452. // 等待确认对话框关闭和网络请求完成
  453. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  454. // 使用更宽松的等待策略
  455. try {
  456. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  457. } catch {
  458. // 继续执行
  459. }
  460. await this.page.waitForTimeout(1000);
  461. }
  462. /**
  463. * 取消状态切换操作
  464. */
  465. async cancelToggleStatus() {
  466. const cancelButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '取消' });
  467. await cancelButton.click();
  468. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  469. }
  470. /**
  471. * 验证区域是否存在
  472. * @param regionName 区域名称
  473. * @returns 区域是否存在
  474. */
  475. async regionExists(regionName: string): Promise<boolean> {
  476. const regionElement = this.treeContainer.getByText(regionName);
  477. return (await regionElement.count()) > 0;
  478. }
  479. /**
  480. * 展开区域节点
  481. * @param regionName 区域名称
  482. */
  483. async expandNode(regionName: string) {
  484. // 找到区域文本元素
  485. const regionText = this.treeContainer.getByText(regionName, { exact: true });
  486. await regionText.waitFor({ state: 'visible', timeout: 5000 });
  487. // 滚动到元素位置
  488. await regionText.scrollIntoViewIfNeeded();
  489. await this.page.waitForTimeout(300);
  490. // 找到区域节点的展开按钮
  491. const regionRow = regionText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  492. const expandButton = regionRow.locator('button').filter({ has: regionRow.locator('svg') }).first();
  493. const count = await expandButton.count();
  494. console.debug('展开按钮数量:', count, 'for', regionName);
  495. if (count > 0) {
  496. // 悬停以确保按钮可见
  497. await regionRow.hover();
  498. await this.page.waitForTimeout(200);
  499. // 点击展开按钮
  500. await expandButton.click({ timeout: 5000 });
  501. // 等待懒加载的子节点出现
  502. // 首先等待一下让展开动画完成
  503. await this.page.waitForTimeout(500);
  504. // 等待市级子节点出现(如果有子节点的话)
  505. // 查找市级子节点(以"市"结尾)
  506. const cityNode = this.treeContainer.getByText(/市$/);
  507. try {
  508. await cityNode.waitFor({ state: 'visible', timeout: 3000 });
  509. console.debug('找到市级子节点');
  510. } catch {
  511. console.debug('没有找到市级子节点,可能没有子节点或加载失败');
  512. }
  513. } else {
  514. console.debug('没有找到展开按钮,可能已经是展开状态或没有子节点');
  515. }
  516. }
  517. /**
  518. * 收起区域节点
  519. * @param regionName 区域名称
  520. */
  521. async collapseNode(regionName: string) {
  522. // 找到区域节点的收起按钮
  523. const regionRow = this.treeContainer.getByText(regionName, { exact: true }).locator('xpath=ancestor::div[contains(@class, "group")][1]');
  524. const collapseButton = regionRow.locator('button').filter({ has: regionRow.locator('svg') }).first();
  525. const count = await collapseButton.count();
  526. if (count > 0) {
  527. await collapseButton.click();
  528. await this.page.waitForTimeout(500);
  529. }
  530. }
  531. /**
  532. * 获取区域的状态
  533. * @param regionName 区域名称
  534. * @returns 区域状态('启用' 或 '禁用')
  535. */
  536. async getRegionStatus(regionName: string): Promise<'启用' | '禁用' | null> {
  537. // 等待树形结构加载完成
  538. await this.waitForTreeLoaded();
  539. // 找到所有匹配的区域文本
  540. const allRegions = this.treeContainer.getByText(regionName, { exact: true });
  541. const count = await allRegions.count();
  542. console.debug(`getRegionStatus: 查找 "${regionName}", 找到 ${count} 个匹配`);
  543. if (count === 0) return null;
  544. // 如果有多个匹配,尝试找到最后一个(最新创建的)
  545. // 因为新创建的区域通常在列表的末尾
  546. const targetIndex = count - 1;
  547. const regionText = allRegions.nth(targetIndex);
  548. // 确保元素可见
  549. await regionText.scrollIntoViewIfNeeded();
  550. await this.page.waitForTimeout(500);
  551. // 根据DOM结构,状态是区域名称后的第4个 generic 元素
  552. // regionName 的父级 generic 下有: name, level, code, status
  553. const regionNameParent = regionText.locator('xpath=..');
  554. // 获取所有子元素
  555. const children = regionNameParent.locator('xpath=./generic');
  556. const childCount = await children.count();
  557. console.debug(`getRegionStatus: regionNameParent 下有 ${childCount} 个子元素`);
  558. // 状态通常是最后一个子元素或倒数第二个
  559. // 根据DOM结构,状态在第4个位置(索引3,从0开始)
  560. if (childCount >= 4) {
  561. const statusElement = children.nth(3);
  562. const statusText = await statusElement.textContent();
  563. console.debug(`getRegionStatus: 从位置3获取状态: "${statusText}"`);
  564. if (statusText === '启用' || statusText === '禁用') {
  565. return statusText;
  566. }
  567. }
  568. // 如果上述方法失败,尝试在父级内查找状态
  569. const enabledText = regionNameParent.getByText('启用', { exact: true });
  570. const disabledText = regionNameParent.getByText('禁用', { exact: true });
  571. const hasEnabled = await enabledText.count() > 0;
  572. const hasDisabled = await disabledText.count() > 0;
  573. console.debug(`getRegionStatus: "${regionName}" hasEnabled=${hasEnabled}, hasDisabled=${hasDisabled}`);
  574. if (hasEnabled && !hasDisabled) return '启用';
  575. if (hasDisabled && !hasEnabled) return '禁用';
  576. // 如果两者都有,需要更精确的选择器
  577. // 状态在操作按钮之前,所以应该先找到状态元素
  578. const allTexts = await regionNameParent.allTextContents();
  579. console.debug(`getRegionStatus: 所有文本内容:`, allTexts);
  580. // 检查最后一个非空文本是否是状态
  581. for (const text of allTexts) {
  582. if (text === '启用') return '启用';
  583. if (text === '禁用') return '禁用';
  584. }
  585. return null;
  586. }
  587. /**
  588. * 创建省
  589. * @param data 省份数据
  590. * @returns 表单提交结果
  591. */
  592. async createProvince(data: RegionData): Promise<FormSubmitResult> {
  593. await this.openCreateProvinceDialog();
  594. await this.fillRegionForm(data);
  595. const result = await this.submitForm();
  596. await this.waitForDialogClosed();
  597. return result;
  598. }
  599. /**
  600. * 创建子区域(市或区)
  601. * @param parentName 父级区域名称
  602. * @param childType 子区域类型
  603. * @param data 子区域数据
  604. * @returns 表单提交结果
  605. */
  606. async createChildRegion(
  607. parentName: string,
  608. childType: '市' | '区',
  609. data: RegionData
  610. ): Promise<FormSubmitResult> {
  611. await this.openAddChildDialog(parentName, childType);
  612. await this.fillRegionForm(data);
  613. const result = await this.submitForm();
  614. await this.waitForDialogClosed();
  615. return result;
  616. }
  617. /**
  618. * 编辑区域
  619. * @param regionName 区域名称
  620. * @param data 更新的区域数据
  621. * @returns 表单提交结果
  622. */
  623. async editRegion(regionName: string, data: RegionData): Promise<FormSubmitResult> {
  624. await this.openEditDialog(regionName);
  625. await this.fillRegionForm(data);
  626. const result = await this.submitForm();
  627. await this.waitForDialogClosed();
  628. return result;
  629. }
  630. /**
  631. * 删除区域
  632. * @param regionName 区域名称
  633. * @returns 是否成功(true = 成功删除, false = 删除失败或取消)
  634. */
  635. async deleteRegion(regionName: string): Promise<boolean> {
  636. await this.openDeleteDialog(regionName);
  637. await this.confirmDelete();
  638. // 等待并检查 Toast 消息
  639. await this.page.waitForTimeout(1000);
  640. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  641. const hasSuccess = await successToast.count() > 0;
  642. return hasSuccess;
  643. }
  644. /**
  645. * 切换区域状态(启用/禁用)
  646. * @param regionName 区域名称
  647. * @returns 是否成功
  648. */
  649. async toggleRegionStatus(regionName: string): Promise<boolean> {
  650. // 先确保区域在树中可见
  651. await this.waitForTreeLoaded();
  652. const exists = await this.regionExists(regionName);
  653. if (!exists) {
  654. console.debug(`⚠️ toggleRegionStatus: 区域 "${regionName}" 在树中未找到`);
  655. }
  656. await this.openToggleStatusDialog(regionName);
  657. await this.confirmToggleStatus();
  658. // 等待并检查 Toast 消息
  659. await this.page.waitForTimeout(1000);
  660. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  661. const hasSuccess = await successToast.count() > 0;
  662. // 等待树形结构刷新以显示更新后的状态
  663. try {
  664. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  665. } catch {
  666. // 继续执行
  667. }
  668. await this.waitForTreeLoaded();
  669. console.debug(`toggleRegionStatus 完成: 区域="${regionName}", hasSuccess=${hasSuccess}`);
  670. return hasSuccess;
  671. }
  672. /**
  673. * 等待树形结构加载完成
  674. */
  675. async waitForTreeLoaded() {
  676. await this.treeContainer.waitFor({ state: 'visible', timeout: 20000 });
  677. // 等待加载文本消失(使用更健壮的选择器)
  678. // 加载文本位于 CardContent 中,带有 text-muted-foreground 类
  679. await this.page.locator('.text-muted-foreground', { hasText: '加载中' }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
  680. }
  681. }