|
@@ -0,0 +1,421 @@
|
|
|
|
|
+import { Page, Locator } from '@playwright/test';
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 区域数据接口
|
|
|
|
|
+ */
|
|
|
|
|
+export interface RegionData {
|
|
|
|
|
+ /** 区域名称 */
|
|
|
|
|
+ name: string;
|
|
|
|
|
+ /** 行政区划代码 */
|
|
|
|
|
+ code?: string;
|
|
|
|
|
+ /** 区域层级(1=省, 2=市, 3=区) */
|
|
|
|
|
+ level?: 1 | 2 | 3;
|
|
|
|
|
+ /** 父级区域ID */
|
|
|
|
|
+ parentId?: number | null;
|
|
|
|
|
+ /** 状态(0=启用, 1=禁用) */
|
|
|
|
|
+ isDisabled?: 0 | 1;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 表单提交结果
|
|
|
|
|
+ */
|
|
|
|
|
+export interface FormSubmitResult {
|
|
|
|
|
+ success: boolean;
|
|
|
|
|
+ hasError: boolean;
|
|
|
|
|
+ hasSuccess: boolean;
|
|
|
|
|
+ errorMessage?: string;
|
|
|
|
|
+ successMessage?: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * 区域管理 Page Object
|
|
|
|
|
+ *
|
|
|
|
|
+ * 用于管理省市区树形结构的 E2E 测试
|
|
|
|
|
+ * 页面路径: /admin/areas
|
|
|
|
|
+ *
|
|
|
|
|
+ * @example
|
|
|
|
|
+ * ```typescript
|
|
|
|
|
+ * const regionPage = new RegionManagementPage(page);
|
|
|
|
|
+ * await regionPage.goto();
|
|
|
|
|
+ * await regionPage.createProvince({ name: '测试省', code: '110000' });
|
|
|
|
|
+ * ```
|
|
|
|
|
+ */
|
|
|
|
|
+export class RegionManagementPage {
|
|
|
|
|
+ readonly page: Page;
|
|
|
|
|
+
|
|
|
|
|
+ // 页面级选择器
|
|
|
|
|
+ readonly pageTitle: Locator;
|
|
|
|
|
+ readonly addProvinceButton: Locator;
|
|
|
|
|
+ readonly treeContainer: Locator;
|
|
|
|
|
+
|
|
|
|
|
+ constructor(page: Page) {
|
|
|
|
|
+ this.page = page;
|
|
|
|
|
+ this.pageTitle = page.getByText('省市区树形管理');
|
|
|
|
|
+ this.addProvinceButton = page.getByRole('button', { name: '新增省' });
|
|
|
|
|
+ this.treeContainer = page.locator('.border.rounded-lg.bg-background');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 导航到区域管理页面
|
|
|
|
|
+ */
|
|
|
|
|
+ async goto() {
|
|
|
|
|
+ await this.page.goto('/admin/areas');
|
|
|
|
|
+ await this.page.waitForLoadState('domcontentloaded');
|
|
|
|
|
+ // 等待页面标题出现
|
|
|
|
|
+ await this.pageTitle.waitFor({ state: 'visible', timeout: 15000 });
|
|
|
|
|
+ // 等待树形结构加载
|
|
|
|
|
+ await this.treeContainer.waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
|
+ await this.expectToBeVisible();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验证页面关键元素可见
|
|
|
|
|
+ */
|
|
|
|
|
+ async expectToBeVisible() {
|
|
|
|
|
+ await this.pageTitle.waitFor({ state: 'visible', timeout: 15000 });
|
|
|
|
|
+ await this.addProvinceButton.waitFor({ state: 'visible', timeout: 10000 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开新增省对话框
|
|
|
|
|
+ */
|
|
|
|
|
+ async openCreateProvinceDialog() {
|
|
|
|
|
+ await this.addProvinceButton.click();
|
|
|
|
|
+ // 等待对话框出现
|
|
|
|
|
+ await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开新增子区域对话框
|
|
|
|
|
+ * @param parentName 父级区域名称
|
|
|
|
|
+ * @param childType 子区域类型('市' 或 '区')
|
|
|
|
|
+ */
|
|
|
|
|
+ async openAddChildDialog(parentName: string, childType: '市' | '区') {
|
|
|
|
|
+ // 找到父级节点并点击对应的"新增市"或"新增区"按钮
|
|
|
|
|
+ const button = this.treeContainer.getByText(parentName)
|
|
|
|
|
+ .locator('../../..')
|
|
|
|
|
+ .getByRole('button', { name: childType === '市' ? '新增市' : '新增区' });
|
|
|
|
|
+
|
|
|
|
|
+ await button.click();
|
|
|
|
|
+ // 等待对话框出现
|
|
|
|
|
+ await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开编辑区域对话框
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ */
|
|
|
|
|
+ async openEditDialog(regionName: string) {
|
|
|
|
|
+ // 找到区域节点并点击"编辑"按钮
|
|
|
|
|
+ const button = this.treeContainer.getByText(regionName)
|
|
|
|
|
+ .locator('../../..')
|
|
|
|
|
+ .getByRole('button', { name: '编辑' });
|
|
|
|
|
+
|
|
|
|
|
+ await button.click();
|
|
|
|
|
+ // 等待对话框出现
|
|
|
|
|
+ await this.page.waitForSelector('[role="dialog"]', { state: 'visible', timeout: 5000 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开删除确认对话框
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ */
|
|
|
|
|
+ async openDeleteDialog(regionName: string) {
|
|
|
|
|
+ // 找到区域节点并点击"删除"按钮
|
|
|
|
|
+ const button = this.treeContainer.getByText(regionName)
|
|
|
|
|
+ .locator('../../..')
|
|
|
|
|
+ .getByRole('button', { name: '删除' });
|
|
|
|
|
+
|
|
|
|
|
+ await button.click();
|
|
|
|
|
+ // 等待删除确认对话框出现
|
|
|
|
|
+ await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 打开状态切换确认对话框
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ */
|
|
|
|
|
+ async openToggleStatusDialog(regionName: string) {
|
|
|
|
|
+ // 找到区域节点并点击"启用"或"禁用"按钮
|
|
|
|
|
+ const button = this.treeContainer.getByText(regionName)
|
|
|
|
|
+ .locator('../../..')
|
|
|
|
|
+ .locator('button', { hasText: /^(启用|禁用)$/ });
|
|
|
|
|
+
|
|
|
|
|
+ await button.click();
|
|
|
|
|
+ // 等待状态切换确认对话框出现
|
|
|
|
|
+ await this.page.waitForSelector('[role="alertdialog"]', { state: 'visible', timeout: 5000 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 填写区域表单
|
|
|
|
|
+ * @param data 区域数据
|
|
|
|
|
+ */
|
|
|
|
|
+ async fillRegionForm(data: RegionData) {
|
|
|
|
|
+ // 等待表单出现
|
|
|
|
|
+ await this.page.waitForSelector('form', { state: 'visible', timeout: 5000 });
|
|
|
|
|
+
|
|
|
|
|
+ // 填写区域名称
|
|
|
|
|
+ if (data.name) {
|
|
|
|
|
+ await this.page.getByLabel('区域名称').fill(data.name);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 填写行政区划代码
|
|
|
|
|
+ if (data.code) {
|
|
|
|
|
+ await this.page.getByLabel('行政区划代码').fill(data.code);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 提交表单
|
|
|
|
|
+ * @returns 表单提交结果
|
|
|
|
|
+ */
|
|
|
|
|
+ async submitForm(): Promise<FormSubmitResult> {
|
|
|
|
|
+ // 点击提交按钮(创建或更新)
|
|
|
|
|
+ const submitButton = this.page.getByRole('button', { name: /^(创建|更新)$/ });
|
|
|
|
|
+ await submitButton.click();
|
|
|
|
|
+
|
|
|
|
|
+ // 等待网络请求完成
|
|
|
|
|
+ await this.page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
|
|
|
+
|
|
|
|
|
+ // 等待 Toast 消息显示
|
|
|
|
|
+ await this.page.waitForTimeout(2000);
|
|
|
|
|
+
|
|
|
|
|
+ // 检查 Toast 消息
|
|
|
|
|
+ const errorToast = this.page.locator('[data-sonner-toast][data-type="error"]');
|
|
|
|
|
+ const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
|
|
|
|
|
+
|
|
|
|
|
+ const hasError = await errorToast.count() > 0;
|
|
|
|
|
+ const hasSuccess = await successToast.count() > 0;
|
|
|
|
|
+
|
|
|
|
|
+ let errorMessage = null;
|
|
|
|
|
+ let successMessage = null;
|
|
|
|
|
+
|
|
|
|
|
+ if (hasError) {
|
|
|
|
|
+ errorMessage = await errorToast.first().textContent();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (hasSuccess) {
|
|
|
|
|
+ successMessage = await successToast.first().textContent();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ success: hasSuccess || (!hasError && !hasSuccess),
|
|
|
|
|
+ hasError,
|
|
|
|
|
+ hasSuccess,
|
|
|
|
|
+ errorMessage: errorMessage ?? undefined,
|
|
|
|
|
+ successMessage: successMessage ?? undefined,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 取消对话框
|
|
|
|
|
+ */
|
|
|
|
|
+ async cancelDialog() {
|
|
|
|
|
+ const cancelButton = this.page.getByRole('button', { name: '取消' });
|
|
|
|
|
+ await cancelButton.click();
|
|
|
|
|
+ await this.waitForDialogClosed();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 等待对话框关闭
|
|
|
|
|
+ */
|
|
|
|
|
+ async waitForDialogClosed() {
|
|
|
|
|
+ const dialog = this.page.locator('[role="dialog"]');
|
|
|
|
|
+ await dialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {});
|
|
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 确认删除操作
|
|
|
|
|
+ */
|
|
|
|
|
+ async confirmDelete() {
|
|
|
|
|
+ const confirmButton = this.page.getByRole('button', { name: /^确认删除$/ });
|
|
|
|
|
+ await confirmButton.click();
|
|
|
|
|
+ // 等待确认对话框关闭和网络请求完成
|
|
|
|
|
+ await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
|
|
|
|
|
+ await this.page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
|
|
|
+ await this.page.waitForTimeout(1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 取消删除操作
|
|
|
|
|
+ */
|
|
|
|
|
+ async cancelDelete() {
|
|
|
|
|
+ const cancelButton = this.page.getByRole('button', { name: '取消' }).and(
|
|
|
|
|
+ this.page.locator('[role="alertdialog"]')
|
|
|
|
|
+ );
|
|
|
|
|
+ await cancelButton.click();
|
|
|
|
|
+ await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 确认状态切换操作
|
|
|
|
|
+ */
|
|
|
|
|
+ async confirmToggleStatus() {
|
|
|
|
|
+ const confirmButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '确认' });
|
|
|
|
|
+ await confirmButton.click();
|
|
|
|
|
+ // 等待确认对话框关闭和网络请求完成
|
|
|
|
|
+ await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
|
|
|
|
|
+ await this.page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
|
|
|
+ await this.page.waitForTimeout(1000);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 取消状态切换操作
|
|
|
|
|
+ */
|
|
|
|
|
+ async cancelToggleStatus() {
|
|
|
|
|
+ const cancelButton = this.page.locator('[role="alertdialog"]').getByRole('button', { name: '取消' });
|
|
|
|
|
+ await cancelButton.click();
|
|
|
|
|
+ await this.page.waitForSelector('[role="alertdialog"]', { state: 'hidden', timeout: 5000 }).catch(() => {});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 验证区域是否存在
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ * @returns 区域是否存在
|
|
|
|
|
+ */
|
|
|
|
|
+ async regionExists(regionName: string): Promise<boolean> {
|
|
|
|
|
+ const regionElement = this.treeContainer.getByText(regionName);
|
|
|
|
|
+ return (await regionElement.count()) > 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 展开区域节点
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ */
|
|
|
|
|
+ async expandNode(regionName: string) {
|
|
|
|
|
+ // 找到区域节点的展开按钮(向右的箭头图标)
|
|
|
|
|
+ const expandButton = this.treeContainer.getByText(regionName)
|
|
|
|
|
+ .locator('../../..')
|
|
|
|
|
+ .locator('button')
|
|
|
|
|
+ .filter({ has: this.page.locator('svg[data-lucide="chevron-right"]') });
|
|
|
|
|
+
|
|
|
|
|
+ const count = await expandButton.count();
|
|
|
|
|
+ if (count > 0) {
|
|
|
|
|
+ await expandButton.first().click();
|
|
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 收起区域节点
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ */
|
|
|
|
|
+ async collapseNode(regionName: string) {
|
|
|
|
|
+ // 找到区域节点的收起按钮(向下的箭头图标)
|
|
|
|
|
+ const collapseButton = this.treeContainer.getByText(regionName)
|
|
|
|
|
+ .locator('../../..')
|
|
|
|
|
+ .locator('button')
|
|
|
|
|
+ .filter({ has: this.page.locator('svg[data-lucide="chevron-down"]') });
|
|
|
|
|
+
|
|
|
|
|
+ const count = await collapseButton.count();
|
|
|
|
|
+ if (count > 0) {
|
|
|
|
|
+ await collapseButton.first().click();
|
|
|
|
|
+ await this.page.waitForTimeout(500);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 获取区域的状态
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ * @returns 区域状态('启用' 或 '禁用')
|
|
|
|
|
+ */
|
|
|
|
|
+ async getRegionStatus(regionName: string): Promise<'启用' | '禁用' | null> {
|
|
|
|
|
+ const regionRow = this.treeContainer.getByText(regionName).locator('../../..');
|
|
|
|
|
+ const statusBadge = regionRow.locator('.badge').filter({ hasText: /^(启用|禁用)$/ });
|
|
|
|
|
+
|
|
|
|
|
+ const count = await statusBadge.count();
|
|
|
|
|
+ if (count === 0) return null;
|
|
|
|
|
+
|
|
|
|
|
+ const text = await statusBadge.first().textContent();
|
|
|
|
|
+ return (text === '启用' || text === '禁用') ? text : null;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 创建省
|
|
|
|
|
+ * @param data 省份数据
|
|
|
|
|
+ * @returns 表单提交结果
|
|
|
|
|
+ */
|
|
|
|
|
+ async createProvince(data: RegionData): Promise<FormSubmitResult> {
|
|
|
|
|
+ await this.openCreateProvinceDialog();
|
|
|
|
|
+ await this.fillRegionForm(data);
|
|
|
|
|
+ const result = await this.submitForm();
|
|
|
|
|
+ await this.waitForDialogClosed();
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 创建子区域(市或区)
|
|
|
|
|
+ * @param parentName 父级区域名称
|
|
|
|
|
+ * @param childType 子区域类型
|
|
|
|
|
+ * @param data 子区域数据
|
|
|
|
|
+ * @returns 表单提交结果
|
|
|
|
|
+ */
|
|
|
|
|
+ async createChildRegion(
|
|
|
|
|
+ parentName: string,
|
|
|
|
|
+ childType: '市' | '区',
|
|
|
|
|
+ data: RegionData
|
|
|
|
|
+ ): Promise<FormSubmitResult> {
|
|
|
|
|
+ await this.openAddChildDialog(parentName, childType);
|
|
|
|
|
+ await this.fillRegionForm(data);
|
|
|
|
|
+ const result = await this.submitForm();
|
|
|
|
|
+ await this.waitForDialogClosed();
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 编辑区域
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ * @param data 更新的区域数据
|
|
|
|
|
+ * @returns 表单提交结果
|
|
|
|
|
+ */
|
|
|
|
|
+ async editRegion(regionName: string, data: RegionData): Promise<FormSubmitResult> {
|
|
|
|
|
+ await this.openEditDialog(regionName);
|
|
|
|
|
+ await this.fillRegionForm(data);
|
|
|
|
|
+ const result = await this.submitForm();
|
|
|
|
|
+ await this.waitForDialogClosed();
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 删除区域
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ * @returns 是否成功(true = 成功删除, false = 删除失败或取消)
|
|
|
|
|
+ */
|
|
|
|
|
+ async deleteRegion(regionName: string): Promise<boolean> {
|
|
|
|
|
+ await this.openDeleteDialog(regionName);
|
|
|
|
|
+ await this.confirmDelete();
|
|
|
|
|
+
|
|
|
|
|
+ // 等待并检查 Toast 消息
|
|
|
|
|
+ await this.page.waitForTimeout(1000);
|
|
|
|
|
+ const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
|
|
|
|
|
+ const hasSuccess = await successToast.count() > 0;
|
|
|
|
|
+
|
|
|
|
|
+ return hasSuccess;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 切换区域状态(启用/禁用)
|
|
|
|
|
+ * @param regionName 区域名称
|
|
|
|
|
+ * @returns 是否成功
|
|
|
|
|
+ */
|
|
|
|
|
+ async toggleRegionStatus(regionName: string): Promise<boolean> {
|
|
|
|
|
+ await this.openToggleStatusDialog(regionName);
|
|
|
|
|
+ await this.confirmToggleStatus();
|
|
|
|
|
+
|
|
|
|
|
+ // 等待并检查 Toast 消息
|
|
|
|
|
+ await this.page.waitForTimeout(1000);
|
|
|
|
|
+ const successToast = this.page.locator('[data-sonner-toast][data-type="success"]');
|
|
|
|
|
+ const hasSuccess = await successToast.count() > 0;
|
|
|
|
|
+
|
|
|
|
|
+ return hasSuccess;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 等待树形结构加载完成
|
|
|
|
|
+ */
|
|
|
|
|
+ async waitForTreeLoaded() {
|
|
|
|
|
+ await this.treeContainer.waitFor({ state: 'visible', timeout: 20000 });
|
|
|
|
|
+ // 等待加载文本消失
|
|
|
|
|
+ await this.page.waitForSelector('text=加载中...', { state: 'hidden', timeout: 10000 }).catch(() => {});
|
|
|
|
|
+ }
|
|
|
|
|
+}
|