region-management.page.ts 28 KB

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