| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503 |
- import { Page, Locator } from '@playwright/test';
- /**
- * 区域层级常量
- */
- export const REGION_LEVEL = {
- PROVINCE: 1,
- CITY: 2,
- DISTRICT: 3,
- } as const;
- /**
- * 区域层级类型
- */
- export type RegionLevel = typeof REGION_LEVEL[keyof typeof REGION_LEVEL];
- /**
- * 区域状态常量
- */
- export const REGION_STATUS = {
- ENABLED: 0,
- DISABLED: 1,
- } as const;
- /**
- * 区域数据接口
- */
- export interface RegionData {
- /** 区域名称 */
- name: string;
- /** 行政区划代码 */
- code?: string;
- /** 区域层级(1=省, 2=市, 3=区) */
- level?: RegionLevel;
- /** 父级区域ID */
- parentId?: number | null;
- /** 状态(0=启用, 1=禁用) */
- isDisabled?: typeof REGION_STATUS[keyof typeof REGION_STATUS];
- }
- /**
- * 网络响应数据
- */
- export interface NetworkResponse {
- url: string;
- method: string;
- status: number;
- ok: boolean;
- responseHeaders: Record<string, string>;
- responseBody: unknown;
- }
- /**
- * 表单提交结果
- */
- export interface FormSubmitResult {
- /** 提交是否成功 */
- success: boolean;
- /** 是否有错误 */
- hasError: boolean;
- /** 是否有成功消息 */
- hasSuccess: boolean;
- /** 错误消息 */
- errorMessage?: string;
- /** 成功消息 */
- successMessage?: string;
- /** 网络响应列表 */
- responses?: NetworkResponse[];
- }
- /**
- * 区域管理 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('省市区树形管理', { exact: true });
- // 使用 role + name 组合获取新增按钮(比单独 text 更健壮)
- this.addProvinceButton = page.getByRole('button', { name: '新增省', exact: true });
- // 使用 Card 组件的结构来定位树形容器(比 Tailwind 类更健壮)
- // 根据实际 DOM: Card > CardContent > AreaTreeAsync > div.border.rounded-lg.bg-background
- this.treeContainer = page.locator('.border.rounded-lg').first();
- }
- /**
- * 导航到区域管理页面
- */
- 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 regionRow = this.treeContainer.getByText(regionName, { exact: true }).locator('xpath=ancestor::div[contains(@class, "group")][1]');
- // 在操作按钮组中查找状态切换按钮(第3个按钮:编辑、状态切换、删除)
- const statusButton = regionRow.getByRole('button').filter({ hasText: /^(启用|禁用)$/ }).and(
- regionRow.locator('xpath=./div[contains(@class, "flex") and contains(@class, "gap")]//button[position()=3]')
- );
- await statusButton.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 responses: NetworkResponse[] = [];
- // 监听所有网络请求
- const responseHandler = async (response: Response) => {
- const url = response.url();
- // 监听区域管理相关的 API 请求
- if (url.includes('/areas') || url.includes('area')) {
- const requestBody = response.request()?.postData();
- const responseBody = await response.text().catch(() => '');
- let jsonBody = null;
- try {
- jsonBody = JSON.parse(responseBody);
- } catch {
- // 不是 JSON 响应
- }
- responses.push({
- url,
- method: response.request()?.method() ?? 'UNKNOWN',
- status: response.status(),
- ok: response.ok(),
- responseHeaders: await response.allHeaders().catch(() => ({})),
- responseBody: jsonBody || responseBody,
- });
- }
- };
- this.page.on('response', responseHandler);
- // 点击提交按钮(创建或更新)
- const submitButton = this.page.getByRole('button', { name: /^(创建|更新)$/ });
- await submitButton.click();
- // 等待网络请求完成
- await this.page.waitForLoadState('networkidle', { timeout: 10000 });
- // 移除监听器
- this.page.off('response', responseHandler);
- // 等待 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: string | null = null;
- let successMessage: string | null = 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,
- responses,
- };
- }
- /**
- * 取消对话框
- */
- 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 regionRow = this.treeContainer.getByText(regionName, { exact: true }).locator('xpath=ancestor::div[contains(@class, "group")][1]');
- const expandButton = regionRow.locator('button').filter({ has: regionRow.locator('svg') }).first();
- const count = await expandButton.count();
- if (count > 0) {
- await expandButton.click();
- await this.page.waitForTimeout(500);
- }
- }
- /**
- * 收起区域节点
- * @param regionName 区域名称
- */
- async collapseNode(regionName: string) {
- // 找到区域节点的收起按钮
- const regionRow = this.treeContainer.getByText(regionName, { exact: true }).locator('xpath=ancestor::div[contains(@class, "group")][1]');
- const collapseButton = regionRow.locator('button').filter({ has: regionRow.locator('svg') }).first();
- const count = await collapseButton.count();
- if (count > 0) {
- await collapseButton.click();
- await this.page.waitForTimeout(500);
- }
- }
- /**
- * 获取区域的状态
- * @param regionName 区域名称
- * @returns 区域状态('启用' 或 '禁用')
- */
- async getRegionStatus(regionName: string): Promise<'启用' | '禁用' | null> {
- const regionRow = this.treeContainer.getByText(regionName, { exact: true }).locator('xpath=ancestor::div[contains(@class, "group")][1]');
- // 使用更精确的选择器:查找包含"启用"或"禁用"文本的 Badge
- // 根据 Badge 变体:启用=variant="default",禁用=variant="secondary"
- 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 });
- // 等待加载文本消失(使用更健壮的选择器)
- // 加载文本位于 CardContent 中,带有 text-muted-foreground 类
- await this.page.locator('.text-muted-foreground', { hasText: '加载中' }).waitFor({ state: 'hidden', timeout: 10000 }).catch(() => {});
- }
- }
|