region-management.page.ts 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729
  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. // 找到区域节点并点击"删除"按钮
  198. const button = this.treeContainer.getByText(regionName)
  199. .locator('../../..')
  200. .getByRole('button', { name: '删除' });
  201. // 等待按钮可见并可点击
  202. await button.waitFor({ state: 'visible', timeout: 5000 });
  203. await button.scrollIntoViewIfNeeded();
  204. await button.click({ timeout: 5000 });
  205. // 等待删除确认对话框出现
  206. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
  207. }
  208. /**
  209. * 打开状态切换确认对话框
  210. * @param regionName 区域名称
  211. */
  212. async openToggleStatusDialog(regionName: string) {
  213. // 等待树形结构加载完成
  214. await this.waitForTreeLoaded();
  215. // 找到所有匹配的区域文本,使用最后一个(最新创建的)
  216. const allRegions = this.treeContainer.getByText(regionName, { exact: true });
  217. const count = await allRegions.count();
  218. if (count === 0) {
  219. throw new Error(`区域 "${regionName}" 未找到`);
  220. }
  221. // 使用最后一个匹配项(最新创建的)
  222. const targetIndex = count - 1;
  223. const regionText = allRegions.nth(targetIndex);
  224. await regionText.waitFor({ state: 'visible', timeout: 5000 });
  225. // 滚动到元素位置
  226. await regionText.scrollIntoViewIfNeeded();
  227. await this.page.waitForTimeout(300);
  228. const regionRow = regionText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  229. await regionRow.hover();
  230. await this.page.waitForTimeout(300);
  231. // 在区域行内查找"启用"或"禁用"按钮(操作按钮组中的状态切换按钮)
  232. const statusButton = regionRow.getByRole('button', { name: /^(启用|禁用)$/ });
  233. await statusButton.waitFor({ state: 'visible', timeout: 3000 });
  234. await statusButton.click({ timeout: 5000 });
  235. // 等待状态切换确认对话框出现
  236. await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
  237. }
  238. /**
  239. * 填写区域表单
  240. * @param data 区域数据
  241. */
  242. async fillRegionForm(data: RegionData) {
  243. // 等待表单出现
  244. await this.page.waitForSelector('form', { state: 'visible', timeout: 5000 });
  245. // 填写区域名称
  246. if (data.name) {
  247. await this.page.getByLabel('区域名称').fill(data.name);
  248. }
  249. // 填写行政区划代码
  250. if (data.code) {
  251. await this.page.getByLabel('行政区划代码').fill(data.code);
  252. }
  253. }
  254. /**
  255. * 提交表单
  256. * @returns 表单提交结果
  257. */
  258. async submitForm(): Promise<FormSubmitResult> {
  259. // 收集网络响应
  260. const responses: NetworkResponse[] = [];
  261. // 监听所有网络请求
  262. const responseHandler = async (response: Response) => {
  263. const url = response.url();
  264. // 监听区域管理相关的 API 请求
  265. if (url.includes('/areas') || url.includes('area')) {
  266. const requestBody = response.request()?.postData();
  267. const responseBody = await response.text().catch(() => '');
  268. let jsonBody = null;
  269. try {
  270. jsonBody = JSON.parse(responseBody);
  271. } catch {
  272. // 不是 JSON 响应
  273. }
  274. responses.push({
  275. url,
  276. method: response.request()?.method() ?? 'UNKNOWN',
  277. status: response.status(),
  278. ok: response.ok(),
  279. responseHeaders: await response.allHeaders().catch(() => ({})),
  280. responseBody: jsonBody || responseBody,
  281. });
  282. }
  283. };
  284. this.page.on('response', responseHandler);
  285. // 点击提交按钮(创建或更新)
  286. const submitButton = this.page.getByRole('button', { name: /^(创建|更新)$/ });
  287. await submitButton.click();
  288. // 等待网络请求完成 - 使用更宽松的策略
  289. // networkidle 可能因后台轮询而失败,使用 domcontentloaded 代替
  290. try {
  291. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  292. } catch {
  293. // domcontentloaded 也可能失败,继续执行
  294. }
  295. // 额外等待,给 API 响应一些时间
  296. await this.page.waitForTimeout(1000);
  297. // 移除监听器
  298. this.page.off('response', responseHandler);
  299. // 等待对话框关闭或错误出现
  300. await this.page.waitForTimeout(1500);
  301. // 检查 Toast 消息
  302. const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
  303. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  304. const hasError = await errorToast.count() > 0;
  305. const hasSuccess = await successToast.count() > 0;
  306. let errorMessage: string | null = null;
  307. let successMessage: string | null = null;
  308. if (hasError) {
  309. errorMessage = await errorToast.first().textContent();
  310. }
  311. if (hasSuccess) {
  312. successMessage = await successToast.first().textContent();
  313. }
  314. return {
  315. success: hasSuccess || (!hasError && !hasSuccess),
  316. hasError,
  317. hasSuccess,
  318. errorMessage: errorMessage ?? undefined,
  319. successMessage: successMessage ?? undefined,
  320. responses,
  321. };
  322. }
  323. /**
  324. * 取消对话框
  325. */
  326. async cancelDialog() {
  327. const cancelButton = this.page.getByRole('button', { name: '取消' });
  328. await cancelButton.click();
  329. await this.waitForDialogClosed();
  330. }
  331. /**
  332. * 等待对话框关闭
  333. */
  334. async waitForDialogClosed() {
  335. const dialog = this.page.locator('[role="dialog"]');
  336. await dialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
  337. await this.page.waitForTimeout(500);
  338. }
  339. /**
  340. * 确认删除操作
  341. */
  342. async confirmDelete() {
  343. const confirmButton = this.page.getByRole('button', { name: /^确认删除$/ });
  344. await confirmButton.click();
  345. // 等待确认对话框关闭和网络请求完成
  346. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  347. // 使用更宽松的等待策略
  348. try {
  349. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  350. } catch {
  351. // 继续执行
  352. }
  353. await this.page.waitForTimeout(1000);
  354. }
  355. /**
  356. * 取消删除操作
  357. */
  358. async cancelDelete() {
  359. const cancelButton = this.page.getByRole('button', { name: '取消' }).and(
  360. this.page.locator('[role="alertdialog"]')
  361. );
  362. await cancelButton.click();
  363. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  364. }
  365. /**
  366. * 确认状态切换操作
  367. */
  368. async confirmToggleStatus() {
  369. // 监听 API 响应
  370. let apiResponse: any = null;
  371. const responseHandler = async (response: Response) => {
  372. const url = response.url();
  373. if (url.includes('/areas') || url.includes('area')) {
  374. try {
  375. const responseBody = await response.text();
  376. apiResponse = {
  377. url,
  378. status: response.status(),
  379. body: responseBody,
  380. };
  381. console.debug(`API 响应: ${url}, status=${response.status()}`);
  382. } catch {
  383. // ignore
  384. }
  385. }
  386. };
  387. this.page.on('response', responseHandler);
  388. const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '确认' });
  389. await confirmButton.click();
  390. // 等待 API 响应
  391. await this.page.waitForTimeout(2000);
  392. this.page.off('response', responseHandler);
  393. if (apiResponse) {
  394. console.debug(`状态切换 API 响应: status=${apiResponse.status}, body=${apiResponse.body}`);
  395. }
  396. // 等待确认对话框关闭和网络请求完成
  397. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  398. // 使用更宽松的等待策略
  399. try {
  400. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  401. } catch {
  402. // 继续执行
  403. }
  404. await this.page.waitForTimeout(1000);
  405. }
  406. /**
  407. * 取消状态切换操作
  408. */
  409. async cancelToggleStatus() {
  410. const cancelButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '取消' });
  411. await cancelButton.click();
  412. await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
  413. }
  414. /**
  415. * 验证区域是否存在
  416. * @param regionName 区域名称
  417. * @returns 区域是否存在
  418. */
  419. async regionExists(regionName: string): Promise<boolean> {
  420. const regionElement = this.treeContainer.getByText(regionName);
  421. return (await regionElement.count()) > 0;
  422. }
  423. /**
  424. * 展开区域节点
  425. * @param regionName 区域名称
  426. */
  427. async expandNode(regionName: string) {
  428. // 找到区域文本元素
  429. const regionText = this.treeContainer.getByText(regionName, { exact: true });
  430. await regionText.waitFor({ state: 'visible', timeout: 5000 });
  431. // 滚动到元素位置
  432. await regionText.scrollIntoViewIfNeeded();
  433. await this.page.waitForTimeout(300);
  434. // 找到区域节点的展开按钮
  435. const regionRow = regionText.locator('xpath=ancestor::div[contains(@class, "group")][1]');
  436. const expandButton = regionRow.locator('button').filter({ has: regionRow.locator('svg') }).first();
  437. const count = await expandButton.count();
  438. console.debug('展开按钮数量:', count, 'for', regionName);
  439. if (count > 0) {
  440. // 悬停以确保按钮可见
  441. await regionRow.hover();
  442. await this.page.waitForTimeout(200);
  443. // 点击展开按钮
  444. await expandButton.click({ timeout: 5000 });
  445. // 等待懒加载的子节点出现
  446. // 首先等待一下让展开动画完成
  447. await this.page.waitForTimeout(500);
  448. // 等待市级子节点出现(如果有子节点的话)
  449. // 查找市级子节点(以"市"结尾)
  450. const cityNode = this.treeContainer.getByText(/市$/);
  451. try {
  452. await cityNode.waitFor({ state: 'visible', timeout: 3000 });
  453. console.debug('找到市级子节点');
  454. } catch {
  455. console.debug('没有找到市级子节点,可能没有子节点或加载失败');
  456. }
  457. } else {
  458. console.debug('没有找到展开按钮,可能已经是展开状态或没有子节点');
  459. }
  460. }
  461. /**
  462. * 收起区域节点
  463. * @param regionName 区域名称
  464. */
  465. async collapseNode(regionName: string) {
  466. // 找到区域节点的收起按钮
  467. const regionRow = this.treeContainer.getByText(regionName, { exact: true }).locator('xpath=ancestor::div[contains(@class, "group")][1]');
  468. const collapseButton = regionRow.locator('button').filter({ has: regionRow.locator('svg') }).first();
  469. const count = await collapseButton.count();
  470. if (count > 0) {
  471. await collapseButton.click();
  472. await this.page.waitForTimeout(500);
  473. }
  474. }
  475. /**
  476. * 获取区域的状态
  477. * @param regionName 区域名称
  478. * @returns 区域状态('启用' 或 '禁用')
  479. */
  480. async getRegionStatus(regionName: string): Promise<'启用' | '禁用' | null> {
  481. // 等待树形结构加载完成
  482. await this.waitForTreeLoaded();
  483. // 找到所有匹配的区域文本
  484. const allRegions = this.treeContainer.getByText(regionName, { exact: true });
  485. const count = await allRegions.count();
  486. console.debug(`getRegionStatus: 查找 "${regionName}", 找到 ${count} 个匹配`);
  487. if (count === 0) return null;
  488. // 如果有多个匹配,尝试找到最后一个(最新创建的)
  489. // 因为新创建的区域通常在列表的末尾
  490. const targetIndex = count - 1;
  491. const regionText = allRegions.nth(targetIndex);
  492. // 确保元素可见
  493. await regionText.scrollIntoViewIfNeeded();
  494. await this.page.waitForTimeout(500);
  495. // 根据DOM结构,状态是区域名称后的第4个 generic 元素
  496. // regionName 的父级 generic 下有: name, level, code, status
  497. const regionNameParent = regionText.locator('xpath=..');
  498. // 获取所有子元素
  499. const children = regionNameParent.locator('xpath=./generic');
  500. const childCount = await children.count();
  501. console.debug(`getRegionStatus: regionNameParent 下有 ${childCount} 个子元素`);
  502. // 状态通常是最后一个子元素或倒数第二个
  503. // 根据DOM结构,状态在第4个位置(索引3,从0开始)
  504. if (childCount >= 4) {
  505. const statusElement = children.nth(3);
  506. const statusText = await statusElement.textContent();
  507. console.debug(`getRegionStatus: 从位置3获取状态: "${statusText}"`);
  508. if (statusText === '启用' || statusText === '禁用') {
  509. return statusText;
  510. }
  511. }
  512. // 如果上述方法失败,尝试在父级内查找状态
  513. const enabledText = regionNameParent.getByText('启用', { exact: true });
  514. const disabledText = regionNameParent.getByText('禁用', { exact: true });
  515. const hasEnabled = await enabledText.count() > 0;
  516. const hasDisabled = await disabledText.count() > 0;
  517. console.debug(`getRegionStatus: "${regionName}" hasEnabled=${hasEnabled}, hasDisabled=${hasDisabled}`);
  518. if (hasEnabled && !hasDisabled) return '启用';
  519. if (hasDisabled && !hasEnabled) return '禁用';
  520. // 如果两者都有,需要更精确的选择器
  521. // 状态在操作按钮之前,所以应该先找到状态元素
  522. const allTexts = await regionNameParent.allTextContents();
  523. console.debug(`getRegionStatus: 所有文本内容:`, allTexts);
  524. // 检查最后一个非空文本是否是状态
  525. for (const text of allTexts) {
  526. if (text === '启用') return '启用';
  527. if (text === '禁用') return '禁用';
  528. }
  529. return null;
  530. }
  531. /**
  532. * 创建省
  533. * @param data 省份数据
  534. * @returns 表单提交结果
  535. */
  536. async createProvince(data: RegionData): Promise<FormSubmitResult> {
  537. await this.openCreateProvinceDialog();
  538. await this.fillRegionForm(data);
  539. const result = await this.submitForm();
  540. await this.waitForDialogClosed();
  541. return result;
  542. }
  543. /**
  544. * 创建子区域(市或区)
  545. * @param parentName 父级区域名称
  546. * @param childType 子区域类型
  547. * @param data 子区域数据
  548. * @returns 表单提交结果
  549. */
  550. async createChildRegion(
  551. parentName: string,
  552. childType: '市' | '区',
  553. data: RegionData
  554. ): Promise<FormSubmitResult> {
  555. await this.openAddChildDialog(parentName, childType);
  556. await this.fillRegionForm(data);
  557. const result = await this.submitForm();
  558. await this.waitForDialogClosed();
  559. return result;
  560. }
  561. /**
  562. * 编辑区域
  563. * @param regionName 区域名称
  564. * @param data 更新的区域数据
  565. * @returns 表单提交结果
  566. */
  567. async editRegion(regionName: string, data: RegionData): Promise<FormSubmitResult> {
  568. await this.openEditDialog(regionName);
  569. await this.fillRegionForm(data);
  570. const result = await this.submitForm();
  571. await this.waitForDialogClosed();
  572. return result;
  573. }
  574. /**
  575. * 删除区域
  576. * @param regionName 区域名称
  577. * @returns 是否成功(true = 成功删除, false = 删除失败或取消)
  578. */
  579. async deleteRegion(regionName: string): Promise<boolean> {
  580. await this.openDeleteDialog(regionName);
  581. await this.confirmDelete();
  582. // 等待并检查 Toast 消息
  583. await this.page.waitForTimeout(1000);
  584. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  585. const hasSuccess = await successToast.count() > 0;
  586. return hasSuccess;
  587. }
  588. /**
  589. * 切换区域状态(启用/禁用)
  590. * @param regionName 区域名称
  591. * @returns 是否成功
  592. */
  593. async toggleRegionStatus(regionName: string): Promise<boolean> {
  594. // 先确保区域在树中可见
  595. await this.waitForTreeLoaded();
  596. const exists = await this.regionExists(regionName);
  597. if (!exists) {
  598. console.debug(`⚠️ toggleRegionStatus: 区域 "${regionName}" 在树中未找到`);
  599. }
  600. await this.openToggleStatusDialog(regionName);
  601. await this.confirmToggleStatus();
  602. // 等待并检查 Toast 消息
  603. await this.page.waitForTimeout(1000);
  604. const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
  605. const hasSuccess = await successToast.count() > 0;
  606. // 等待树形结构刷新以显示更新后的状态
  607. try {
  608. await this.page.waitForLoadState('domcontentloaded', { timeout: 5000 });
  609. } catch {
  610. // 继续执行
  611. }
  612. await this.waitForTreeLoaded();
  613. console.debug(`toggleRegionStatus 完成: 区域="${regionName}", hasSuccess=${hasSuccess}`);
  614. return hasSuccess;
  615. }
  616. /**
  617. * 等待树形结构加载完成
  618. */
  619. async waitForTreeLoaded() {
  620. await this.treeContainer.waitFor({ state: 'visible', timeout: 20000 });
  621. // 等待加载文本消失(使用更健壮的选择器)
  622. // 加载文本位于 CardContent 中,带有 text-muted-foreground 类
  623. await this.page.locator('.text-muted-foreground', { hasText: '加载中' }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
  624. }
  625. }