Commit 1a4c1b8b authored by liguangyu06's avatar liguangyu06
Browse files

从 temp 选择性同步框架与用例到 main

合入自动化代码、用例 CSV/MD/spec/PO 及登录态修复;不含 knowledge-base、.cursor、data/scan 等 temp 全量备份内容。
parent f6eec849
import type { Locator, Page } from '@playwright/test';
import { WhFlowSharedLocators } from './wh-flow-shared.locator';
/** 探针绑定。选择器来自 locators/spd/warehouse-probe.locator.yaml */
export class WarehouseProbeLocators extends WhFlowSharedLocators {
readonly queryBtn: Locator;
readonly resetBtn: Locator;
readonly bindBtn: Locator;
readonly modalCancel: Locator;
constructor(page: Page) {
super(page);
this.queryBtn = page.getByRole('button', { name: /查\s*询/ });
this.resetBtn = page.getByRole('button', { name: /重\s*置/ });
this.bindBtn = page.getByRole('button', { name: '绑定货位' });
this.modalCancel = page.getByRole('button', { name: /取\s*消/ });
}
probeIdInput(): Locator {
return this.page.locator('.ant-form-item').filter({ hasText: '探针ID' }).locator('input').first();
}
hostCodeInput(): Locator {
return this.page.locator('.ant-form-item').filter({ hasText: '主机编码' }).locator('input').first();
}
bindSelect(): Locator {
return this.page.locator('.ant-form-item').filter({ hasText: '是否绑定货位' }).locator('.ant-select').first();
}
bindModal(): Locator {
return this.page.locator('.ant-modal:visible').filter({ hasText: /货位绑定探针/ }).first();
}
confirmCountBtn(root?: Locator): Locator {
const scope = root ?? this.page;
return scope.getByRole('button', { name: /确\s*定/ }).first();
}
}
# Auto-generated from warehouse-probe-elements.json
# Source: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/storehouse/probe
# Scanned: 2026-10-20T09:05:17.123Z
# Page: 基础设置 > 库房管理 > 探针绑定
# Note: filter dropdown was opened during scan
page: warehouse-probe
module: spd-basic
url: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/storehouse/probe
locators:
# --- main::main ---
menu_home:
description: "首页"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "首页"
playwright: "page.getByRole('menuitem', { name: '首页' })"
menu_basicSetting:
description: "基础设置"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "基础设置"
playwright: "page.getByRole('menuitem', { name: '基础设置' })"
menu_purchase:
description: "采购管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "采购管理"
playwright: "page.getByRole('menuitem', { name: '采购管理' })"
menu_opening:
description: "期初管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "期初管理"
playwright: "page.getByRole('menuitem', { name: '期初管理' })"
menu_centerStock:
description: "中心库管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "中心库管理"
playwright: "page.getByRole('menuitem', { name: '中心库管理' })"
menu_deptBusiness:
description: "科室业务管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "科室业务管理"
playwright: "page.getByRole('menuitem', { name: '科室业务管理' })"
menu_finance:
description: "财务管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "财务管理"
playwright: "page.getByRole('menuitem', { name: '财务管理' })"
menu_orthoVolume:
description: "骨科带量"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "骨科带量"
playwright: "page.getByRole('menuitem', { name: '骨科带量' })"
menu_general:
description: "综合管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "综合管理"
playwright: "page.getByRole('menuitem', { name: '综合管理' })"
menu_yangcai:
description: "阳采管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "阳采管理"
playwright: "page.getByRole('menuitem', { name: '阳采管理' })"
menu_processCenter:
description: "流程中心"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "流程中心"
playwright: "page.getByRole('menuitem', { name: '流程中心' })"
menu_logistics:
description: "后勤物资管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "后勤物资管理"
playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })"
menu_statReport:
description: "统计报表"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "统计报表"
playwright: "page.getByRole('menuitem', { name: '统计报表' })"
menu_dataViz:
description: "数据可视化"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "数据可视化"
playwright: "page.getByRole('menuitem', { name: '数据可视化' })"
menu_el_e5ba93e58cbae8aebee7bdae:
description: "库区设置"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "库区设置"
playwright: "page.getByRole('menuitem', { name: '库区设置' })"
menu_el_e68ea2e99288e7bb91e5ae9a:
description: "探针绑定"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "探针绑定"
playwright: "page.getByRole('menuitem', { name: '探针绑定' })"
menu_el_e58d8ee6b885e9809ae98193:
description: "华清通道"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "华清通道"
playwright: "page.getByRole('menuitem', { name: '华清通道' })"
el_e68ea2e99288e7bb91e5ae9a:
description: "探针绑定"
element_type: tab
section: "main::main"
strategy: text
value: "探针绑定"
playwright: "page.getByText('探针绑定')"
tab_el_e68ea2e99288e7bb91e5ae9a:
description: "探针绑定"
element_type: div
section: "main::main"
strategy: id
value: "rc-tabs-0-tab-/basic/storehouse/storehouse/probe"
playwright: "page.locator('#rc-tabs-0-tab-/basic/storehouse/storehouse/probe')"
el_e68ea2e992884944Input:
description: "请输入"
element_type: text
section: "main::main"
strategy: id
value: "form_item_cname"
playwright: "page.locator('#form_item_cname')"
el_e68ea2e992884944Input_2:
description: "请输入"
element_type: text
section: "main::main"
strategy: id
value: "form_item_cname"
playwright: "page.locator('#form_item_cname')"
el_e698afe590a6e7bb91e5ae9ae8b4a7e4bd8dSelect:
description: "请选择"
element_type: select
section: "main::main"
strategy: label_text
value: "是否绑定货位"
playwright: "page.getByText('是否绑定货位', { exact: true })"
searchBtn:
description: "查 询"
element_type: button
section: "main::main"
strategy: role
role: button
name: "查 询"
playwright: "page.getByRole('button', { name: '查 询' })"
resetBtn:
description: "重 置"
element_type: button
section: "main::main"
strategy: role
role: button
name: "重 置"
playwright: "page.getByRole('button', { name: '重 置' })"
tableDensity:
description: "column-height"
element_type: span
section: "main::main"
strategy: label
value: "column-height"
playwright: "page.getByLabel('column-height')"
8Btn:
description: "28"
element_type: button
section: "main::main"
strategy: role
role: button
name: "28"
playwright: "page.getByRole('button', { name: '28' })"
el_e7bb91e5ae9ae8b4a7e4bd8dBtn:
description: "绑定货位"
element_type: button
section: "main::main"
strategy: role
role: button
name: "绑定货位"
playwright: "page.getByRole('button', { name: '绑定货位' })"
Btn:
description: "3"
element_type: button
section: "main::main"
strategy: role
role: button
name: "3"
playwright: "page.getByRole('button', { name: '3' })"
# --- main::pagination ---
pagination_el_e585b13131e69da1313530e69da12fe9a1b5:
description: "共 11 条150 条/页"
element_type: ul
section: "main::pagination"
strategy: text
value: "共 11 条150 条/页"
playwright: "page.getByText('共 11 条150 条/页')"
pagination_prevPageBtn:
description: "上一页"
element_type: li
section: "main::pagination"
strategy: text
value: "上一页"
playwright: "page.getByText('上一页')"
pagination_page_1:
description: "1"
element_type: pagination_item
section: "main::pagination"
strategy: css
value: ".ant-pagination-item-active"
playwright: "page.locator('.ant-pagination-item-active')"
pagination_nextPageBtn:
description: "下一页"
element_type: li
section: "main::pagination"
strategy: text
value: "下一页"
playwright: "page.getByText('下一页')"
pagination_pageSizeSelect:
description: "50 条/页"
element_type: select
section: "main::pagination"
strategy: text
value: "50 条/页"
playwright: "page.getByText('50 条/页')"
# --- main::modal:货位绑定探针 ---
el_e5bd93e5898de5ba93e688bfSelect:
description: "千泉山院区中心库房"
element_type: select
section: "main::modal:货位绑定探针"
strategy: label_text
value: "当前库房"
playwright: "page.getByText('当前库房', { exact: true })"
el_e5bd93e5898de5ba93e688bfInput:
description: "当前库房"
element_type: search
section: "main::modal:货位绑定探针"
strategy: id
value: "form_item_deptId"
playwright: "page.locator('#form_item_deptId')"
el_e5bd93e5898de5ba93e58cbaSelect:
description: "当前库区"
element_type: select
section: "main::modal:货位绑定探针"
strategy: label_text
value: "当前库区"
playwright: "page.getByText('当前库区', { exact: true })"
el_e5bd93e5898de5ba93e58cbaInput:
description: "当前库区"
element_type: search
section: "main::modal:货位绑定探针"
strategy: id
value: "form_item_areaCode"
playwright: "page.locator('#form_item_areaCode')"
el_e8b4a7e4bd8de69fa5e8afa2Input:
description: "请输入"
element_type: text
section: "main::modal:货位绑定探针"
strategy: id
value: "form_item_shelfNo"
playwright: "page.locator('#form_item_shelfNo')"
el_e8b4a7e4bd8de78ab6e68081Select:
description: "货位状态"
element_type: select
section: "main::modal:货位绑定探针"
strategy: label_text
value: "货位状态"
playwright: "page.getByText('货位状态', { exact: true })"
el_e9bb98e8aea4e8b4a7e4bd8dSelect:
description: "默认货位"
element_type: select
section: "main::modal:货位绑定探针"
strategy: label_text
value: "默认货位"
playwright: "page.getByText('默认货位', { exact: true })"
el_e585b132363236e69da13132333435e280a2e280a2e280a235333530e69da12fe9a1b5e8b7b3e887b3e9a1b5:
description: "共 2626 条12345•••5350 条/页跳至页"
element_type: ul
section: "main::modal:货位绑定探针"
strategy: text
value: "共 2626 条12345•••5350 条/页跳至页"
playwright: "page.getByText('共 2626 条12345•••5350 条/页跳至页')"
prevPageBtn:
description: "上一页"
element_type: li
section: "main::modal:货位绑定探针"
strategy: text
value: "上一页"
playwright: "page.getByText('上一页')"
page_1:
description: "1"
element_type: pagination_item
section: "main::modal:货位绑定探针"
strategy: css
value: ".ant-pagination-item-active"
playwright: "page.locator('.ant-pagination-item-active')"
page_2:
description: "2"
element_type: pagination_item
section: "main::modal:货位绑定探针"
strategy: text
value: "2"
playwright: "page.getByText('2')"
page_3:
description: "3"
element_type: pagination_item
section: "main::modal:货位绑定探针"
strategy: text
value: "3"
playwright: "page.getByText('3')"
page_4:
description: "4"
element_type: pagination_item
section: "main::modal:货位绑定探针"
strategy: text
value: "4"
playwright: "page.getByText('4')"
page_5:
description: "5"
element_type: pagination_item
section: "main::modal:货位绑定探针"
strategy: text
value: "5"
playwright: "page.getByText('5')"
el_e59091e5908e35e9a1b5:
description: "向后 5 页"
element_type: li
section: "main::modal:货位绑定探针"
strategy: text
value: "向后 5 页"
playwright: "page.getByText('向后 5 页')"
page_53:
description: "53"
element_type: pagination_item
section: "main::modal:货位绑定探针"
strategy: text
value: "53"
playwright: "page.getByText('53')"
nextPageBtn:
description: "下一页"
element_type: li
section: "main::modal:货位绑定探针"
strategy: text
value: "下一页"
playwright: "page.getByText('下一页')"
pageSizeSelect:
description: "50 条/页"
element_type: select
section: "main::modal:货位绑定探针"
strategy: text
value: "50 条/页"
playwright: "page.getByText('50 条/页')"
cancelBtn:
description: "取 消"
element_type: button
section: "main::modal:货位绑定探针"
strategy: role
role: button
name: "取 消"
playwright: "page.getByRole('button', { name: '取 消' })"
confirmBtn:
description: "确定(0)"
element_type: button
section: "main::modal:货位绑定探针"
strategy: role
role: button
name: "确定(0)"
playwright: "page.getByRole('button', { name: '确定(0)' })"
import type { Locator, Page } from '@playwright/test';
import { WhFlowSharedLocators } from './wh-flow-shared.locator';
/** 库区设置。选择器来自 locators/spd/warehouse-zone.locator.yaml */
export class WarehouseZoneLocators extends WhFlowSharedLocators {
readonly warehouseNameInput: Locator;
readonly zoneSearch: Locator;
readonly syncBtn: Locator;
readonly addBtn: Locator;
readonly tree: Locator;
readonly shelfNo: Locator;
readonly shelfName: Locator;
readonly address: Locator;
readonly cancelBtn: Locator;
readonly confirmBtn: Locator;
constructor(page: Page) {
super(page);
this.warehouseNameInput = page.getByPlaceholder('库房名称');
this.zoneSearch = page.getByPlaceholder('库区编号/名称');
this.syncBtn = page.getByRole('button', { name: '同步' });
this.addBtn = page.getByRole('button', { name: /新\s*增/ });
this.tree = page.locator('.ant-tree').first();
this.shelfNo = page.locator('#basic_shelfNo');
this.shelfName = page.locator('#basic_shelfName');
this.address = page.locator('#basic_address');
this.cancelBtn = page.getByRole('button', { name: /取\s*消/ });
this.confirmBtn = page.getByRole('button', { name: /确\s*定/ });
}
treeTitles(): Locator {
return this.tree.locator('.ant-tree-title');
}
selectedTreeTitle(): Locator {
return this.tree.locator('.ant-tree-treenode-selected .ant-tree-title').first();
}
}
# Auto-generated from warehouse-zone-elements.json
# Source: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/platform/storehouse/area
# Scanned: 2026-10-20T09:08:25.864Z
# Page: 基础设置 > 库房管理 > 库区设置
# Note: dialog/modal was opened during scan
# Note: edit dialog was opened during scan
page: warehouse-zone
module: spd-basic
url: http://spdtest.cmic.com.cn:8080/spd/basic/storehouse/platform/storehouse/area
locators:
# --- main::main ---
menu_home:
description: "首页"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "首页"
playwright: "page.getByRole('menuitem', { name: '首页' })"
menu_basicSetting:
description: "基础设置"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "基础设置"
playwright: "page.getByRole('menuitem', { name: '基础设置' })"
menu_purchase:
description: "采购管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "采购管理"
playwright: "page.getByRole('menuitem', { name: '采购管理' })"
menu_opening:
description: "期初管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "期初管理"
playwright: "page.getByRole('menuitem', { name: '期初管理' })"
menu_centerStock:
description: "中心库管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "中心库管理"
playwright: "page.getByRole('menuitem', { name: '中心库管理' })"
menu_deptBusiness:
description: "科室业务管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "科室业务管理"
playwright: "page.getByRole('menuitem', { name: '科室业务管理' })"
menu_finance:
description: "财务管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "财务管理"
playwright: "page.getByRole('menuitem', { name: '财务管理' })"
menu_orthoVolume:
description: "骨科带量"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "骨科带量"
playwright: "page.getByRole('menuitem', { name: '骨科带量' })"
menu_general:
description: "综合管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "综合管理"
playwright: "page.getByRole('menuitem', { name: '综合管理' })"
menu_yangcai:
description: "阳采管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "阳采管理"
playwright: "page.getByRole('menuitem', { name: '阳采管理' })"
menu_processCenter:
description: "流程中心"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "流程中心"
playwright: "page.getByRole('menuitem', { name: '流程中心' })"
menu_logistics:
description: "后勤物资管理"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "后勤物资管理"
playwright: "page.getByRole('menuitem', { name: '后勤物资管理' })"
menu_statReport:
description: "统计报表"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "统计报表"
playwright: "page.getByRole('menuitem', { name: '统计报表' })"
menu_dataViz:
description: "数据可视化"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "数据可视化"
playwright: "page.getByRole('menuitem', { name: '数据可视化' })"
menu_el_e5ba93e58cbae8aebee7bdae:
description: "库区设置"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "库区设置"
playwright: "page.getByRole('menuitem', { name: '库区设置' })"
menu_el_e68ea2e99288e7bb91e5ae9a:
description: "探针绑定"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "探针绑定"
playwright: "page.getByRole('menuitem', { name: '探针绑定' })"
menu_el_e58d8ee6b885e9809ae98193:
description: "华清通道"
element_type: li
section: "main::main"
strategy: role
role: menuitem
name: "华清通道"
playwright: "page.getByRole('menuitem', { name: '华清通道' })"
el_e5ba93e58cbae8aebee7bdae:
description: "库区设置"
element_type: tab
section: "main::main"
strategy: text
value: "库区设置"
playwright: "page.getByText('库区设置')"
tab_el_e5ba93e58cbae8aebee7bdae:
description: "库区设置"
element_type: div
section: "main::main"
strategy: id
value: "rc-tabs-0-tab-/basic/storehouse/platform/storehouse/area"
playwright: "page.locator('#rc-tabs-0-tab-/basic/storehouse/platform/storehouse/area')"
el_e5908ce6ada5Btn:
description: "同步"
element_type: button
section: "main::main"
strategy: role
role: button
name: "同步"
playwright: "page.getByRole('button', { name: '同步' })"
el_e5ba93e688bfe4bfa1e681afInput:
description: "库房名称"
element_type: text
section: "main::main"
strategy: placeholder
value: "库房名称"
playwright: "page.getByPlaceholder('库房名称')"
tree_node_e58589e8b0b7:
description: "光谷院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "光谷院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*光谷院区中心库房\\s*$/ })"
tree_node_e6b4aae5b1b1:
description: "洪山院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "洪山院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*洪山院区中心库房\\s*$/ })"
tree_node_31323035e999:
description: "1205院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "1205院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*1205院区中心库房\\s*$/ })"
tree_node_e7bb93e7ae97:
description: "结算院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "结算院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*结算院区中心库房\\s*$/ })"
tree_node_e59b9ee5bd92:
description: "回归测试院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "回归测试院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*回归测试院区中心库房\\s*$/ })"
tree_node_e5a4a9e5bf83:
description: "天心阁院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "天心阁院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*天心阁院区中心库房\\s*$/ })"
tree_node_e58d83e6b389:
description: "千泉山院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "千泉山院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*千泉山院区中心库房\\s*$/ })"
tree_node_e9a9ace78e8b:
description: "马王堆院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "马王堆院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*马王堆院区中心库房\\s*$/ })"
tree_node_e4b8bbe999a2:
description: "主院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "主院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*主院区中心库房\\s*$/ })"
tree_node_e5b2b3e9ba93:
description: "岳麓山院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "岳麓山院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*岳麓山院区中心库房\\s*$/ })"
tree_node_e695b0e68dae:
description: "数据检查院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "数据检查院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*数据检查院区中心库房\\s*$/ })"
tree_node_e4b89ce59c9f:
description: "东土城院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "东土城院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*东土城院区中心库房\\s*$/ })"
tree_node_e699bae883bd:
description: "智能院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "智能院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*智能院区中心库房\\s*$/ })"
tree_node_e887aae58aa8:
description: "自动采购/请领建议专用院区中心库房"
element_type: tree_title
section: "main::main"
strategy: tree_node
value: "自动采购/请领建议专用院区中心库房"
tree: ".ant-tree"
playwright: "page.locator('.ant-tree .ant-tree-title').filter({ hasText: /^\\s*自动采购/请领建议专用院区中心库房\\s*$/ })"
addBtn:
description: "新 增"
element_type: button
section: "main::main"
strategy: role
role: button
name: "新 增"
playwright: "page.getByRole('button', { name: '新 增' })"
addInput:
description: "库区编号/名称"
element_type: text
section: "main::main"
strategy: placeholder
value: "库区编号/名称"
playwright: "page.getByPlaceholder('库区编号/名称')"
el_e5ba93e688bfe4bfa1e681af:
description: "column-height"
element_type: span
section: "main::main"
strategy: label
value: "column-height"
playwright: "page.getByLabel('column-height')"
editBtn:
description: "编辑"
element_type: button
section: "main::main"
strategy: role
role: button
name: "编辑"
playwright: "page.getByRole('button', { name: '编辑' })"
el_e4babae59198e7aea1e79086Btn:
description: "人员管理"
element_type: button
section: "main::main"
strategy: role
role: button
name: "人员管理"
playwright: "page.getByRole('button', { name: '人员管理' })"
el_e8b4a7e4bd8de7aea1e79086Btn:
description: "货位管理"
element_type: button
section: "main::main"
strategy: role
role: button
name: "货位管理"
playwright: "page.getByRole('button', { name: '货位管理' })"
disableBtn:
description: "停用"
element_type: button
section: "main::main"
strategy: role
role: button
name: "停用"
playwright: "page.getByRole('button', { name: '停用' })"
enableBtn:
description: "启用"
element_type: button
section: "main::main"
strategy: role
role: button
name: "启用"
playwright: "page.getByRole('button', { name: '启用' })"
# --- main::pagination ---
pagination_el_e5ba93e688bfe4bfa1e681af:
description: "共 5 条150 条/页"
element_type: ul
section: "main::pagination"
strategy: label_text
value: "库房信息"
playwright: "page.getByText('库房信息', { exact: true })"
pagination_prevPageBtn:
description: "上一页"
element_type: li
section: "main::pagination"
strategy: label_text
value: "库房信息"
playwright: "page.getByText('库房信息', { exact: true })"
pagination_el_e5ba93e688bfe4bfa1e681afBtn:
description: "库房信息"
element_type: button
section: "main::pagination"
strategy: label_text
value: "库房信息"
playwright: "page.getByText('库房信息', { exact: true })"
pagination_page_1:
description: "1"
element_type: pagination_item
section: "main::pagination"
strategy: css
value: ".ant-pagination-item-active"
playwright: "page.locator('.ant-pagination-item-active')"
pagination_nextPageBtn:
description: "下一页"
element_type: li
section: "main::pagination"
strategy: label_text
value: "库房信息"
playwright: "page.getByText('库房信息', { exact: true })"
pagination_pageSizeSelect:
description: "50 条/页"
element_type: select
section: "main::pagination"
strategy: label_text
value: "库房信息"
playwright: "page.getByText('库房信息', { exact: true })"
# --- main::modal:新增库区 ---
el_e5ba93e58cbae7bc96e58fb7Input:
description: "请输入"
element_type: text
section: "main::modal:新增库区"
strategy: id
value: "basic_shelfNo"
playwright: "page.locator('#basic_shelfNo')"
storageAreaInput:
description: "请输入"
element_type: text
section: "main::modal:新增库区"
strategy: id
value: "basic_shelfName"
playwright: "page.locator('#basic_shelfName')"
el_e5ba93e58cbae59cb0e59d80Input:
description: "请输入"
element_type: text
section: "main::modal:新增库区"
strategy: id
value: "basic_address"
playwright: "page.locator('#basic_address')"
el_e590a6:
description: "否"
element_type: radio
section: "main::modal:新增库区"
strategy: text
value: "否"
playwright: "page.getByText('否')"
el_e698af:
description: "是"
element_type: radio
section: "main::modal:新增库区"
strategy: text
value: "是"
playwright: "page.getByText('是')"
el_e88194e7b3bbe4babaSelect:
description: "联系人"
element_type: select
section: "main::modal:新增库区"
strategy: label_text
value: "联系人"
playwright: "page.getByText('联系人', { exact: true })"
el_e88194e7b3bbe4babaInput:
description: "联系人"
element_type: search
section: "main::modal:新增库区"
strategy: id
value: "basic_linkman"
playwright: "page.locator('#basic_linkman')"
el_e88194e7b3bbe696b9e5bc8fInput:
description: "请输入"
element_type: text
section: "main::modal:新增库区"
strategy: id
value: "basic_recPhone"
playwright: "page.locator('#basic_recPhone')"
cancelBtn:
description: "取 消"
element_type: button
section: "main::modal:新增库区"
strategy: role
role: button
name: "取 消"
playwright: "page.getByRole('button', { name: '取 消' })"
confirmBtn:
description: "确 定"
element_type: button
section: "main::modal:新增库区"
strategy: role
role: button
name: "确 定"
playwright: "page.getByRole('button', { name: '确 定' })"
# --- main::modal:编辑库区信息 ---
el_e5ba93e58cbae7bc96e58fb7Input_2:
description: "请输入"
element_type: text
section: "main::modal:编辑库区信息"
strategy: id
value: "basic_shelfNo"
playwright: "page.locator('#basic_shelfNo')"
storageAreaInput_2:
description: "请输入"
element_type: text
section: "main::modal:编辑库区信息"
strategy: id
value: "basic_shelfName"
playwright: "page.locator('#basic_shelfName')"
el_e5ba93e58cbae59cb0e59d80Input_2:
description: "请输入"
element_type: text
section: "main::modal:编辑库区信息"
strategy: id
value: "basic_address"
playwright: "page.locator('#basic_address')"
el_e590a6_2:
description: "否"
element_type: radio
section: "main::modal:编辑库区信息"
strategy: text
value: "否"
playwright: "page.getByText('否')"
el_e698af_2:
description: "是"
element_type: radio
section: "main::modal:编辑库区信息"
strategy: text
value: "是"
playwright: "page.getByText('是')"
el_e88194e7b3bbe4babaSelect_2:
description: "蒋中心库"
element_type: select
section: "main::modal:编辑库区信息"
strategy: label_text
value: "联系人"
playwright: "page.getByText('联系人', { exact: true })"
el_e88194e7b3bbe4babaInput_2:
description: "联系人"
element_type: search
section: "main::modal:编辑库区信息"
strategy: id
value: "basic_linkman"
playwright: "page.locator('#basic_linkman')"
el_e88194e7b3bbe696b9e5bc8fInput_2:
description: "请输入"
element_type: text
section: "main::modal:编辑库区信息"
strategy: id
value: "basic_recPhone"
playwright: "page.locator('#basic_recPhone')"
import type { Locator, Page } from '@playwright/test';
/** 库房管理 / 流程设置共用。叶子菜单是 .ant-menu-item。 */
export class WhFlowSharedLocators {
readonly menuBasic: Locator;
readonly tableHeader: Locator;
readonly empty: Locator;
readonly paginationTotal: Locator;
readonly nextPage: Locator;
readonly prevPage: Locator;
readonly formError: Locator;
constructor(protected readonly page: Page) {
this.menuBasic = page.getByRole('menuitem', { name: '基础设置' });
this.tableHeader = page.locator('.ant-table-thead th');
this.empty = page.locator('.ant-empty, .ant-empty-description').first();
this.paginationTotal = page.locator('.ant-pagination-total-text').first();
this.nextPage = page.locator('.ant-pagination-next').first();
this.prevPage = page.locator('.ant-pagination-prev').first();
this.formError = page.locator('.ant-form-item-explain-error, .ant-message-notice, .ant-notification-notice');
}
groupSubmenuTitle(name: string): Locator {
return this.page.locator('.ant-menu-submenu-title').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) });
}
menuLeaf(name: string): Locator {
return this.page.locator('.ant-menu-item').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first();
}
button(name: string, root: Locator | Page = this.page): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return root.getByRole('button', { name: pattern });
}
putPage(): Locator {
return this.page.locator('.put-page').last();
}
dialog(): Locator {
return this.page.locator('.ant-modal-wrap .ant-modal:visible, .ant-drawer-open').last();
}
}
......@@ -24,19 +24,13 @@
"test:page": "node scripts/run-page.mjs",
"suites": "node scripts/run-ci.mjs --list",
"flake:report": "node scripts/flake-report.mjs",
"failures:report": "node scripts/failure-report.mjs",
"quarantine:sync": "node scripts/sync-quarantine.mjs",
"quarantine:apply": "node scripts/sync-quarantine.mjs --write",
"email:summary": "node scripts/ci-email-summary.mjs",
"test:quarantine": "node scripts/run-ci.mjs --suite=quarantine",
"test:smoke": "playwright test tests/spd/login.spec.ts tests/spd/home.spec.ts --grep @smoke",
"test:smoke:allure": "ALLURE=1 npm run test:smoke && npm run allure:generate",
"test:stat-iod-002": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-002.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-003": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-003.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-004": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-006": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-006.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-043": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-043.spec.ts --headed --project=chromium --workers=1",
"test:stat-iod-044": "SLOW_MO=500 playwright test tests/spd/center-report-sheet.stat-iod-044.spec.ts --headed --project=chromium --workers=1",
"test:stat-ies-027": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-entry.stat-ies-027.spec.ts --headed --project=chromium --workers=1",
"test:stat-oos-004": "SLOW_MO=500 playwright test tests/spd/center-report-out.stat-oos-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-oos-006": "SLOW_MO=500 playwright test tests/spd/center-report-out.stat-oos-006.spec.ts --headed --project=chromium --workers=1",
"test:center-report": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium",
"test:center-report:all": "playwright test tests/spd/center-report-sheet.spec.ts --project=chromium --workers=3",
"test:center-report-entry": "playwright test tests/spd/center-report-entry.spec.ts --project=chromium",
......@@ -49,12 +43,6 @@
"test:center-report-lmm-sheet:all": "playwright test tests/spd/center-report-lmm-sheet.spec.ts --project=chromium",
"test:center-report-turnover": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium",
"test:center-report-turnover:all": "playwright test tests/spd/center-report-turnover.spec.ts --project=chromium",
"test:stat-ptr-002": "SLOW_MO=500 playwright test tests/spd/center-report-turnover.stat-ptr-002.spec.ts --headed --project=chromium --workers=1",
"test:stat-lms-005": "SLOW_MO=500 playwright test tests/spd/center-report-lmm-sheet.stat-lms-005.spec.ts --headed --project=chromium --workers=1",
"test:stat-nos-007": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-net-out.stat-nos-007.spec.ts --headed --project=chromium --workers=1",
"test:stat-nes-007": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-net-entry.stat-nes-007.spec.ts --headed --project=chromium --workers=1",
"test:stat-psi-004": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-psi.stat-psi-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-lmp-004": "SLOW_MO=500 PWDEBUG=console playwright test tests/spd/center-report-lmm-psi.stat-lmp-004.spec.ts --headed --project=chromium --workers=1",
"test:center-report-lmm-psi": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium",
"test:center-report-lmm-psi:all": "playwright test tests/spd/center-report-lmm-psi.spec.ts --project=chromium",
"test:center-report-net-entry": "playwright test tests/spd/center-report-net-entry.spec.ts --project=chromium",
......@@ -84,9 +72,78 @@
"discover:scan-invoice-trace": "node scripts/scan-invoice-trace-batch.mjs",
"discover:scan-consume": "node scripts/scan-consume-batch.mjs",
"discover:scan-extended": "node scripts/scan-extended-report-batch.mjs",
"discover:scan-admit": "node scripts/scan-admit-batch.mjs",
"discover:scan-basic": "node scripts/scan-basic-batch.mjs",
"discover:scan-vendor": "node scripts/scan-vendor-batch.mjs",
"discover:scan-surgery": "node scripts/scan-surgery-batch.mjs",
"discover:scan-user": "node scripts/scan-user-batch.mjs",
"discover:scan-warehouse": "node scripts/scan-wh-flow-batch.mjs warehouse-zone warehouse-probe",
"discover:scan-flow": "node scripts/scan-wh-flow-batch.mjs flow-def flow-inst",
"discover:scan-wh-flow": "node scripts/scan-wh-flow-batch.mjs",
"discover:yaml-from-json-basic": "node scripts/json-to-locator-yaml.mjs --all-new",
"generate:testpoints-vendor": "python3 scripts/generate-vendor-testpoints.py",
"generate:testpoints-user": "python3 scripts/generate-user-testpoints.py",
"generate:testpoints-warehouse": "python3 scripts/generate-wh-flow-testpoints.py warehouse-zone warehouse-probe",
"generate:testpoints-flow": "python3 scripts/generate-wh-flow-testpoints.py flow-def flow-inst",
"generate:testpoints-wh-flow": "python3 scripts/generate-wh-flow-testpoints.py",
"generate:md-user": "python3 scripts/generate-user-md.py",
"generate:md-wh-flow": "python3 scripts/generate-wh-flow-md.py",
"generate:md-csv-wh-flow": "python3 scripts/generate-wh-flow-md.py",
"test:warehouse-zone": "playwright test tests/spd/warehouse-zone.spec.ts --project=chromium --grep @smoke",
"test:warehouse-zone:all": "playwright test tests/spd/warehouse-zone.spec.ts --project=chromium --workers=1",
"test:warehouse-probe": "playwright test tests/spd/warehouse-probe.spec.ts --project=chromium --grep @smoke",
"test:warehouse-probe:all": "playwright test tests/spd/warehouse-probe.spec.ts --project=chromium --workers=1",
"test:flow-def": "playwright test tests/spd/flow-def.spec.ts --project=chromium --grep @smoke",
"test:flow-def:all": "playwright test tests/spd/flow-def.spec.ts --project=chromium --workers=1",
"test:flow-inst": "playwright test tests/spd/flow-inst.spec.ts --project=chromium --grep @smoke",
"test:flow-inst:all": "playwright test tests/spd/flow-inst.spec.ts --project=chromium --workers=1",
"test:wh-flow:smoke": "playwright test tests/spd/warehouse-zone.spec.ts tests/spd/warehouse-probe.spec.ts tests/spd/flow-def.spec.ts tests/spd/flow-inst.spec.ts --project=chromium --grep @smoke --workers=1",
"test:wh-flow:all": "playwright test tests/spd/warehouse-zone.spec.ts tests/spd/warehouse-probe.spec.ts tests/spd/flow-def.spec.ts tests/spd/flow-inst.spec.ts --project=chromium --workers=1",
"assert:wh-flow-csv-spec": "node scripts/assert-wh-flow-csv-spec-sync.mjs",
"assert:wh-flow-isolation": "node scripts/assert-vendor-no-reuse-open.mjs",
"generate:md-csv-user": "python3 scripts/generate-user-md.py",
"test:basic-user": "playwright test tests/spd/basic-user.spec.ts --project=chromium --grep @smoke",
"test:basic-user:all": "playwright test tests/spd/basic-user.spec.ts --project=chromium --workers=1",
"test:basic-user-union": "playwright test tests/spd/basic-user-union.spec.ts --project=chromium --grep @smoke",
"test:basic-user-union:all": "playwright test tests/spd/basic-user-union.spec.ts --project=chromium --workers=1",
"test:basic-role": "playwright test tests/spd/basic-role.spec.ts --project=chromium --grep @smoke",
"test:basic-role:all": "playwright test tests/spd/basic-role.spec.ts --project=chromium --workers=1",
"test:user:smoke": "playwright test tests/spd/basic-user.spec.ts tests/spd/basic-user-union.spec.ts tests/spd/basic-role.spec.ts --project=chromium --grep @smoke --workers=1",
"test:user:all": "playwright test tests/spd/basic-user.spec.ts tests/spd/basic-user-union.spec.ts tests/spd/basic-role.spec.ts --project=chromium --workers=1",
"assert:user-csv-spec": "node scripts/assert-user-csv-spec-sync.mjs",
"assert:basic-csv-spec": "node scripts/assert-basic-csv-spec-sync.mjs",
"test:vendor-query": "playwright test tests/spd/vendor-query.spec.ts --project=chromium --grep @smoke",
"test:vendor-query:all": "playwright test tests/spd/vendor-query.spec.ts --project=chromium --workers=1",
"test:vendor-qual": "playwright test tests/spd/vendor-qual.spec.ts --project=chromium --grep @smoke",
"test:vendor-qual:all": "playwright test tests/spd/vendor-qual.spec.ts --project=chromium --workers=1",
"test:vendor:smoke": "playwright test tests/spd/vendor-query.spec.ts tests/spd/vendor-qual.spec.ts tests/spd/vendor-audit.spec.ts tests/spd/vendor-cert-diff.spec.ts tests/spd/vendor-eval-scheme.spec.ts tests/spd/vendor-eval-indicator.spec.ts tests/spd/vendor-holiday.spec.ts --project=chromium --grep @smoke --workers=1",
"test:vendor-audit:smoke": "playwright test tests/spd/vendor-audit.spec.ts --project=chromium --grep @smoke --workers=1",
"test:vendor-audit:all": "playwright test tests/spd/vendor-audit.spec.ts --project=chromium --workers=1",
"test:vendor-cert-diff": "playwright test tests/spd/vendor-cert-diff.spec.ts --project=chromium --grep @smoke",
"test:vendor-cert-diff:all": "playwright test tests/spd/vendor-cert-diff.spec.ts --project=chromium --workers=1",
"test:vendor-eval-scheme": "playwright test tests/spd/vendor-eval-scheme.spec.ts --project=chromium --grep @smoke",
"test:vendor-eval-scheme:all": "playwright test tests/spd/vendor-eval-scheme.spec.ts --project=chromium --workers=1",
"test:vendor-eval-indicator": "playwright test tests/spd/vendor-eval-indicator.spec.ts --project=chromium --grep @smoke",
"test:vendor-eval-indicator:all": "playwright test tests/spd/vendor-eval-indicator.spec.ts --project=chromium --workers=1",
"test:vendor-holiday": "playwright test tests/spd/vendor-holiday.spec.ts --project=chromium --grep @smoke",
"test:vendor-holiday:all": "playwright test tests/spd/vendor-holiday.spec.ts --project=chromium --workers=1",
"generate:md-csv-vendor": "python3 scripts/generate-vendor-md-csv.py",
"assert:vendor-csv-spec": "node scripts/assert-vendor-csv-spec-sync.mjs",
"assert:vendor-isolation": "node scripts/assert-vendor-no-reuse-open.mjs",
"test:surgery-base-pkg": "playwright test tests/spd/surgery-base-pkg.spec.ts --project=chromium --grep @smoke",
"test:surgery-base-pkg:all": "playwright test tests/spd/surgery-base-pkg.spec.ts --project=chromium --workers=1",
"test:surgery-oper-type": "playwright test tests/spd/surgery-oper-type.spec.ts --project=chromium --grep @smoke",
"test:surgery-oper-type:all": "playwright test tests/spd/surgery-oper-type.spec.ts --project=chromium --workers=1",
"test:surgery-std-pkg": "playwright test tests/spd/surgery-std-pkg.spec.ts --project=chromium --grep @smoke",
"test:surgery-std-pkg:all": "playwright test tests/spd/surgery-std-pkg.spec.ts --project=chromium --workers=1",
"test:surgery:smoke": "playwright test tests/spd/surgery-base-pkg.spec.ts tests/spd/surgery-oper-type.spec.ts tests/spd/surgery-std-pkg.spec.ts --project=chromium --grep @smoke --workers=1",
"test:surgery:all": "playwright test tests/spd/surgery-base-pkg.spec.ts tests/spd/surgery-oper-type.spec.ts tests/spd/surgery-std-pkg.spec.ts --project=chromium --workers=1",
"assert:surgery-csv-spec": "node scripts/assert-surgery-csv-spec-sync.mjs",
"test:vendor:all": "playwright test tests/spd/vendor-query.spec.ts tests/spd/vendor-qual.spec.ts tests/spd/vendor-audit.spec.ts tests/spd/vendor-cert-diff.spec.ts tests/spd/vendor-eval-scheme.spec.ts tests/spd/vendor-eval-indicator.spec.ts tests/spd/vendor-holiday.spec.ts --project=chromium --workers=1",
"discover:yaml-from-json": "node scripts/json-to-locator-yaml.mjs --all-extended",
"update:ui-specs-extended": "python3 scripts/update-extended-ui-specs.py",
"update:ui-specs-consume": "python3 scripts/update-consume-ui-specs.py",
"sync:ui-specs-user": "python3 scripts/sync-docs-ui-specs.py --only USER-001,USER-002,USER-003",
"generate:testpoints-invoice-trace": "python3 scripts/generate-invoice-trace-testpoints.py",
"generate:testpoints-consume": "python3 scripts/generate-consume-testpoints.py",
"generate:testpoints-extended": "python3 scripts/generate-extended-testpoints.py",
......@@ -111,9 +168,6 @@
"test:invoice-balance-status": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium",
"test:invoice-balance-status:all": "playwright test tests/spd/invoice-balance-status.spec.ts --project=chromium --workers=1",
"test:trace-direct-buy": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium",
"test:stat-tby-004": "SLOW_MO=500 playwright test tests/spd/trace-direct-buy.stat-tby-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-tbc-004": "SLOW_MO=500 playwright test tests/spd/trace-barcode.stat-tbc-004.spec.ts --headed --project=chromium --workers=1",
"test:stat-tdb-004": "SLOW_MO=500 playwright test tests/spd/trace-dept-buy.stat-tdb-004.spec.ts --headed --project=chromium --workers=1",
"test:trace-direct-buy:all": "playwright test tests/spd/trace-direct-buy.spec.ts --project=chromium --workers=1",
"test:trace-barcode": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium",
"test:trace-barcode:all": "playwright test tests/spd/trace-barcode.spec.ts --project=chromium --workers=1",
......
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 物料准入单列表。详情里的「通过」只取消,不确认审批。 */
export class AdmitMaterialListPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.materialListPath,
pathPart: 'productOrder/zrlist',
menu: admitData.menuMaterialList,
forbidden: ['supplyApply/', 'accessKind/', 'productApply/'],
});
}
async ready(): Promise<void> {
await this.expectOnPage();
}
async filterByOrderOrFallback(): Promise<void> {
await this.query();
await this.queryByFirstAvailable([
{ column: '准入单号', filter: '准入单号', kind: 'input' },
{ column: '注册证号', filter: '注册证号', kind: 'input' },
{ column: '产品名称', filter: '产品名称', kind: 'input' },
{ column: '器械注册人', filter: '器械注册人', kind: 'input' },
]);
}
async resetFilters(): Promise<void> {
await this.query();
const filled = await this.fillFirstVisibleFilter();
await this.reset();
if (filled) await this.expectInputCleared(filled);
await this.expectListOrEmpty();
}
async mockEmpty() {
const cert = this.textInput('注册证号');
if (await cert.isVisible().catch(() => false)) {
await this.fillFilter('注册证号', admitData.mockCert);
} else {
await this.fillFilter('产品名称', admitData.mockGoods);
}
await this.query();
return this.snapshotList();
}
async coreHeaders(): Promise<string[]> {
await this.query();
return this.headerTexts();
}
async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/);
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const order = await this.tryCell(row, '准入单号');
await this.openPutFromRow(row, '详情');
if (order) await expect(this.put()).toContainText(order.slice(0, 8));
await expect(this.put().getByText('审批进程').first()).toBeVisible();
await this.leaveWithoutCommit();
}
async detailCountMatches(): Promise<void> {
await this.query();
const row = await this.firstRow();
const countText = (await this.cellText(row, '产品条数')).replace(/\s/g, '');
await this.openPutFromRow(row, '详情');
await expect(this.put()).toContainText(countText);
await this.leaveWithoutCommit();
}
async openApproval(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
await this.openDetailSection('审批进程');
await this.leaveWithoutCommit();
}
async exportDetail(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const exp = this.button('导出', this.put()).first();
if (!(await exp.isVisible().catch(() => false))) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('详情无导出');
}
await this.assertBeforeBusinessAction('详情导出');
await this.click(exp, '导出');
await this.assertPageHealthy('详情导出');
expect(this.page.url()).not.toMatch(/\/login/);
await expect(this.put().getByRole('button', { name: /返\s*回/ }).first()).toBeVisible();
await this.leaveWithoutCommit();
}
async detailSearchMock(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称');
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
await this.leaveWithoutCommit();
}
async passedNotConfirm(): Promise<void> {
await this.skipIfTabEmpty('已通过');
const row = await this.firstRow();
const order = await this.tryCell(row, '准入单号');
await this.openPutFromRow(row, '详情');
const pass = this.button('通过', this.put()).first();
const visible = await pass.isVisible().catch(() => false);
const disabled = visible && ((await pass.isDisabled().catch(() => false)) || ((await pass.getAttribute('class')) ?? '').includes('disabled'));
if (!visible || disabled) {
await this.leaveWithoutCommit();
} else {
await this.click(pass, '通过');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
if (await confirm.isVisible().catch(() => false)) {
await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消通过');
}
await this.leaveWithoutCommit();
}
await this.clickTab('已通过');
if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible();
}
private async fillFirstVisibleFilter(): Promise<string> {
for (const label of ['注册证号', '产品名称', '器械注册人']) {
if (await this.textInput(label).isVisible().catch(() => false)) {
await this.fillFilter(label, '样');
return label;
}
}
return '';
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'text', value: '样本' },
{ label: '耗材类型', kind: 'select', value: '样本' },
{ label: '器械注册人', kind: 'text', value: '样本' },
{ label: '注册证号', kind: 'text', value: '样本' },
{ label: '供应商', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 物料准入管理。写操作只取消,不确认生成/作废/关联。 */
export class AdmitMaterialPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.materialPath,
pathPart: 'productApply/ZRlist',
menu: admitData.menuMaterial,
forbidden: ['productOrder/', 'supplyApply/', 'accessKind/'],
});
}
async ready(): Promise<void> {
await this.expectOnPage();
}
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
async filterByGoodsType(): Promise<void> {
await this.query();
const type = await this.sampleCell('耗材类型');
await this.chooseSelect('耗材类型', type);
await this.query();
await this.expectRowsContain('耗材类型', type);
}
async filterByNameAndType(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
const type = await this.sampleCell('耗材类型');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.chooseSelect('耗材类型', type);
await this.query();
await this.expectRowsContain('产品名称', name);
await this.expectRowsContain('耗材类型', type);
}
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
}
async collapseKeepsName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.collapseKeeps('产品名称', name.slice(0, 20));
}
async mockEmptyName() {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
return this.snapshotList();
}
async coreHeaders(): Promise<string[]> {
await this.query();
return this.headerTexts();
}
async switchPending(): Promise<void> {
await this.clickTab('待申请');
await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/);
await this.expectListOrEmpty();
}
async pendingCountMatches(): Promise<void> {
await this.clickTabAndMatchCount('待申请');
}
async generateNeedsSelection(): Promise<void> {
await this.expectNeedsSelection('生成申请单');
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '详情');
await expect(this.put()).toContainText(name.slice(0, 8));
await this.leaveWithoutCommit();
await expect(this.button('查询').first()).toBeVisible();
}
async detailReadonly(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const save = this.button('保存', this.put());
expect(await save.count(), '详情不应提供保存').toBe(0);
const nameInput = this.putInput('产品名称');
if (await nameInput.isVisible().catch(() => false)) {
await expect(nameInput).toBeDisabled();
}
await this.leaveWithoutCommit();
}
async editBackfill(): Promise<void> {
await this.clickTab('待申请');
const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称');
const spec = await this.tryCell(row, '规格型号');
await this.openPutFromRow(row, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
if (spec && (await this.putInput('规格型号').isVisible().catch(() => false))) {
await expect(this.putInput('规格型号')).toHaveValue(spec);
}
await this.leaveWithoutCommit();
}
async editCancel(): Promise<void> {
await this.clickTab('待申请');
const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '编辑');
const input = this.putInput('产品名称');
await this.fill(input, `${name}-取消`, '临时改名');
await this.leaveWithoutCommit();
await this.clickTab('待申请');
const again = await this.skipIfNoAction('编辑', '待申请无编辑');
await this.openPutFromRow(again, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
await this.leaveWithoutCommit();
}
async editClearName(): Promise<void> {
await this.clickTab('待申请');
const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '编辑');
await this.fill(this.putInput('产品名称'), '', '清空产品名称');
await this.saveOnPut();
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
await this.clickTab('待申请');
const again = await this.rowWithAction('编辑');
expect(again, '取消后原行仍应可编辑').toBeTruthy();
await this.openPutFromRow(again as NonNullable<typeof again>, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
await this.leaveWithoutCommit();
}
async effectiveNoEdit(): Promise<void> {
await this.skipIfTabEmpty('已生效');
const edit = (await this.listRows()).getByRole('button', { name: /编\s*辑/ });
expect(await edit.count(), '已生效行不应提供编辑').toBe(0);
}
async relateCancel(): Promise<void> {
await this.query();
const total = await this.getTotalCount();
const supplier = await this.cellText(await this.firstRow(), '默认进院供应商');
await this.clickToolbarThenCancel('关联产品');
expect(await this.getTotalCount(), '取消关联后总数不变').toBe(total);
expect(await this.cellText(await this.firstRow(), '默认进院供应商')).toBe(supplier);
}
async voidCancel(): Promise<void> {
await this.query();
const voidBefore = await this.readTabCount('已作废');
const row = await this.checkFirstRow();
const name = await this.cellText(row, '产品名称');
await this.clickButton('作废');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
await expect(confirm, '作废应出现确认框').toBeVisible();
await expect(confirm.getByRole('button', { name: /取\s*消/ })).toBeVisible();
await expect(confirm.getByRole('button', { name: /确\s*定/ })).toBeVisible();
await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消作废');
await expect(this.page.getByText(name.slice(0, 12)).first(), '取消后该行仍在').toBeVisible();
if (voidBefore !== null) {
expect(await this.readTabCount('已作废'), '已作废计数不变').toBe(voidBefore);
}
}
async openLog(): Promise<void> {
await this.query();
const row = await this.skipIfNoAction('日志', '列表无日志入口');
await this.assertBeforeBusinessAction('日志');
await this.click(this.rowAction(row, '日志'), '日志');
const layer = this.page.locator('.ant-modal:visible, .ant-drawer:visible, .put-page:visible').last();
await expect(layer, '日志层应打开').toBeVisible();
await this.assertUiReady('日志');
await this.leaveWithoutCommit();
}
}
import { expect, type Page } from '@playwright/test';
import { admitData, uniqProductName } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 产品准入申请。新增与作废只取消,不确认保存、导入或作废。 */
export class AdmitProductApplyPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.productApplyPath,
pathPart: 'productApply/list',
menu: admitData.menuProductApply,
forbidden: ['ZRlist', 'productOrder/', 'supplyApply/', 'accessKind/'],
});
}
async ready(): Promise<void> {
await this.expectOnPage();
}
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
async filterByNameAndType(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
const type = await this.sampleCell('耗材类型');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.chooseSelect('耗材类型', type);
await this.query();
await this.expectRowsContain('产品名称', name);
}
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
}
async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
return this.snapshotList();
}
async coreHeaders(): Promise<string[]> {
await this.query();
return this.headerTexts();
}
async switchPending(): Promise<void> {
await this.clickTab('待申请');
await expect(this.tab('待申请')).toHaveClass(/ant-tabs-tab-active/);
await this.expectListOrEmpty();
}
async goPage2(): Promise<void> {
await this.query();
await this.goToPage2('产品名称');
}
async jumpPage2(): Promise<void> {
await this.query();
await this.jumpToPage(2);
}
async openAddAndBack(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增产品');
await expect(this.put(), '新增产品页').toBeVisible();
for (const label of ['产品名称', '器械注册人', '规格型号']) {
await expect(this.put().getByText(label).first(), label).toBeVisible();
}
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async addCancel(): Promise<void> {
const total = await this.getTotalCount();
const name = uniqProductName();
await this.clickButton('新增产品');
await expect(this.put()).toBeVisible();
await this.fill(this.putInput('产品名称'), name, '填写新产品名称');
await this.leaveWithoutCommit();
await this.fillFilter('产品名称', name);
await this.query();
await this.expectMockEmpty();
await this.reset();
expect(await this.getTotalCount(), '取消后总数不变').toBe(total);
}
async excelCancel(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('Excel导入');
const layer = this.page.locator('.ant-modal:visible, .put-page:visible, .ant-drawer:visible').last();
await expect(layer, 'Excel 导入层').toBeVisible();
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async emptyNameBlocked(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增产品');
await expect(this.put()).toBeVisible();
await this.fill(this.putInput('产品名称'), '', '产品名称保持空');
await this.saveOnPut();
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
expect(await this.getTotalCount(), '空名称不应落库').toBe(total);
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '详情');
await expect(this.put()).toContainText(name.slice(0, 8));
await this.leaveWithoutCommit();
}
async editBackfill(): Promise<void> {
await this.clickTab('待申请');
const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
await this.leaveWithoutCommit();
}
async editCancel(): Promise<void> {
await this.clickTab('待申请');
const row = await this.skipIfNoAction('编辑', '待申请无编辑');
const name = await this.cellText(row, '产品名称');
await this.openPutFromRow(row, '编辑');
await this.fill(this.putInput('产品名称'), `${name}-取消`, '临时改名');
await this.leaveWithoutCommit();
await this.clickTab('待申请');
const again = await this.skipIfNoAction('编辑', '待申请无编辑');
await this.openPutFromRow(again, '编辑');
await expect(this.putInput('产品名称')).toHaveValue(name);
await this.leaveWithoutCommit();
}
async voidCancel(): Promise<void> {
await this.query();
const voidBefore = await this.readTabCount('已作废');
await this.checkFirstRow();
const name = await this.cellText(await this.firstRow(), '产品名称');
await this.clickButton('作废');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
await expect(confirm).toBeVisible();
await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消作废');
await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible();
if (voidBefore !== null) expect(await this.readTabCount('已作废')).toBe(voidBefore);
}
async passedNoEdit(): Promise<void> {
await this.skipIfTabEmpty('已通过');
expect(await (await this.listRows()).getByRole('button', { name: /编\s*辑/ }).count(), '已通过不应提供编辑').toBe(0);
}
}
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 产品准入单列表。与物料单列表 URL 分离;通过只取消。 */
export class AdmitProductListPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.productListPath,
pathPart: 'productOrder/list',
menu: admitData.menuProductList,
forbidden: ['zrlist', 'productApply/', 'supplyApply/', 'accessKind/'],
});
}
async ready(): Promise<void> {
await this.expectOnPage();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'text', value: '样本' },
{ label: '耗材类型', kind: 'select', value: '样本' },
{ label: '器械注册人', kind: 'text', value: '样本' },
{ label: '注册证号', kind: 'text', value: '样本' },
{ label: '供应商', kind: 'text', value: '样本' },
],
{ scope },
);
}
async filterByProductOrFallback(): Promise<void> {
await this.query();
await this.queryByFirstAvailable([
{ column: '产品名称', filter: '产品名称', kind: 'input' },
{ column: '注册证号', filter: '注册证号', kind: 'input' },
{ column: '器械注册人', filter: '器械注册人', kind: 'input' },
]);
}
async resetFilters(): Promise<void> {
await this.query();
const label = (await this.textInput('产品名称').isVisible().catch(() => false)) ? '产品名称' : '注册证号';
await this.fillFilter(label, '样');
await this.reset();
await this.expectInputCleared(label);
await this.expectListOrEmpty();
}
async mockEmpty() {
if (await this.textInput('产品名称').isVisible().catch(() => false)) {
await this.fillFilter('产品名称', admitData.mockGoods);
} else {
await this.fillFilter('注册证号', admitData.mockCert);
}
await this.query();
return this.snapshotList();
}
async coreHeaders(): Promise<string[]> {
await this.query();
return this.headerTexts();
}
async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/);
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const order = await this.tryCell(row, '准入单号');
await this.openPutFromRow(row, '详情');
if (order) await expect(this.put()).toContainText(order.slice(0, 8));
await this.leaveWithoutCommit();
}
async detailCountMatches(): Promise<void> {
await this.query();
const row = await this.firstRow();
const countText = (await this.cellText(row, '产品条数')).replace(/\s/g, '');
await this.openPutFromRow(row, '详情');
await expect(this.put()).toContainText(countText);
await this.leaveWithoutCommit();
}
async openBillLog(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
await this.openDetailSection('单据日志');
await this.leaveWithoutCommit();
}
async detailSearchMock(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称');
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
await this.leaveWithoutCommit();
}
async passedNotConfirm(): Promise<void> {
await this.skipIfTabEmpty('已通过');
const row = await this.firstRow();
const order = await this.tryCell(row, '准入单号');
await this.openPutFromRow(row, '详情');
const pass = this.button('通过', this.put()).first();
const visible = await pass.isVisible().catch(() => false);
const disabled = visible && ((await pass.isDisabled().catch(() => false)) || ((await pass.getAttribute('class')) ?? '').includes('disabled'));
if (!visible || disabled) {
await this.leaveWithoutCommit();
} else {
await this.click(pass, '通过');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
if (await confirm.isVisible().catch(() => false)) {
await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消通过');
}
await this.leaveWithoutCommit();
}
await this.clickTab('已通过');
if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible();
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { AdmitSharedLocators } from '../../locators/spd/admit-shared.locator';
import { BasePage } from '../../core/base.page';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type AdmitMeta = {
path: string;
/** URL pathname 中用于区分兄弟页的片段,如 productApply/ZRlist */
pathPart: string;
menu: string;
/** 进入后 URL 不应包含的片段 */
forbidden?: string[];
};
const DANGER_CONFIRM = /作废|通过|提交|分配|删除|生成/;
/** 准入管理六页共用:无 iframe、侧栏叶子菜单、表格、Tab、详情层只取消不提交。 */
export class AdmitSharedPage extends BasePage {
readonly locators: AdmitSharedLocators;
constructor(
page: Page,
protected readonly meta: AdmitMeta,
) {
super(page);
this.locators = new AdmitSharedLocators(page);
}
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
async openFromMenu(): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.locators.menuBasic, '展开基础设置');
const title = this.locators.admitSubmenuTitle.first();
const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first();
const opened = await submenu
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, '展开准入管理');
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
}
await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible();
expect((await this.headerTexts()).length, '列表表头').toBeGreaterThan(0);
}
button(name: string, root?: Locator): Locator {
return this.locators.button(name, root ?? this.page);
}
async clickButton(name: string, root?: Locator): Promise<void> {
await this.click(this.button(name, root).first(), name);
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
textInput(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
const byRole = scope.getByRole('textbox', { name: label }).first();
return byRole.or(scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first());
}
formSelect(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first();
}
async fillFilter(label: string, value: string): Promise<void> {
const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible();
await this.fill(input, value, `填写${label}`);
}
async chooseSelect(label: string, optionText: string, root?: Locator): Promise<void> {
const select = this.formSelect(label, root);
await this.click(select, `打开${label}`);
const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
const keyword = optionText.replace(/\s/g, '').slice(0, 20);
const search = dropdown.locator('input').first();
if (await search.isVisible().catch(() => false)) {
await search.fill(keyword);
await this.waitSettle(200);
}
const option = dropdown.locator('.ant-select-item-option').filter({ hasText: keyword }).first();
await this.click(option, `选择${keyword}`);
await this.waitSettle(200);
}
async selectFirstOption(label: string, root?: Locator): Promise<boolean> {
const select = this.formSelect(label, root);
if (!(await select.isVisible().catch(() => false))) return false;
await this.click(select, `打开${label}`);
const option = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.first();
if (!(await option.isVisible().catch(() => false))) return false;
await this.click(option, `选择${label}首项`);
await this.waitSettle(200);
return true;
}
tab(name: string): Locator {
return this.locators.tab(name);
}
async readTabCount(name: string): Promise<number | null> {
const text = ((await this.tab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const matched = text.match(/\((\d+)\)/) ?? text.match(/(\d+)$/);
return matched ? Number(matched[1]) : null;
}
async clickTab(name: string): Promise<void> {
await this.assertBeforeBusinessAction(`切换${name}`);
await this.click(this.tab(name), `切换${name}`);
await this.waitForTableReady();
}
put(): Locator {
return this.locators.putPage();
}
async headerCells(): Promise<Locator> {
const groups = this.page.locator('.ant-table-thead');
const count = await groups.count();
let best = groups.first().locator('th');
let bestLen = 0;
for (let i = 0; i < count; i += 1) {
const cells = groups.nth(i).locator('th');
const len = await cells.count();
if (len > bestLen) {
bestLen = len;
best = cells;
}
}
return best;
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
}
async expectHeaders(names: string[]): Promise<void> {
const text = (await this.headerTexts()).join(' ');
for (const name of names) {
expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, ''));
}
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, '')));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async listRows(): Promise<Locator> {
const bodies = this.page.locator('.ant-table-tbody');
const count = await bodies.count();
let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
let bestWidth = -1;
for (let i = 0; i < count; i += 1) {
const body = bodies.nth(i);
const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page'))).catch(() => false);
if (insidePut) continue;
const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first();
const width = await row.locator('td').count().catch(() => 0);
if (width > bestWidth) {
bestWidth = width;
best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)');
}
}
return best;
}
async getDataRowCount(): Promise<number> {
return (await this.listRows()).count();
}
async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async tryCell(row: Locator, header: string): Promise<string> {
try {
return (await this.cellText(row, header)).trim();
} catch {
return '';
}
}
async firstRow(): Promise<Locator> {
const row = (await this.listRows()).first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
/** 列表快照供用例层断言(不在此 expect)。 */
async snapshotList(): Promise<{ rows: number; total: number; emptyVisible: boolean }> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const emptyVisible =
(await this.locators.empty.isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
/** @deprecated 优先在 spec 用 snapshotList + assertAdmitListOrEmpty */
async expectListOrEmpty(): Promise<void> {
const state = await this.snapshotList();
if (state.rows > 0) {
expect(state.total, '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1);
return;
}
expect(state.emptyVisible || state.total === 0, '无数据时空态应明确').toBeTruthy();
}
/** @deprecated 优先在 spec 用 snapshotList + assertAdmitMockEmpty */
async expectMockEmpty(): Promise<void> {
const state = await this.snapshotList();
expect(state.rows === 0 || state.emptyVisible, '无匹配应空态').toBeTruthy();
expect(state.total === 0 || state.rows === 0, '分页总数应为 0').toBeTruthy();
}
/** 只读:抽样行某列是否含关键字(用例层断言)。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
async expectRowsContain(header: string, keyword: string): Promise<void> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
expect(count, `「${header}」查询应有结果`).toBeGreaterThan(0);
const limit = Math.min(count, 5);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
expect(text, `第 ${i + 1} 行${header}应包含关键字`).toContain(needle);
}
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
/** 制单日期保持默认,只用样本列去点已有筛选项。 */
async queryByFirstAvailable(
pairs: Array<{ column: string; filter: string; kind: 'input' | 'select' }>,
): Promise<void> {
const row = await this.firstRow();
for (const pair of pairs) {
const value = (await this.tryCell(row, pair.column)).trim();
if (!value || value === '--' || value === '-') continue;
if (pair.kind === 'input' && (await this.textInput(pair.filter).isVisible().catch(() => false))) {
await this.fillFilter(pair.filter, value.slice(0, 40));
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
if (pair.kind === 'select' && (await this.formSelect(pair.filter).isVisible().catch(() => false))) {
await this.chooseSelect(pair.filter, value);
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
}
await this.guardedSkip('筛选区没有与首行样本对应的条件');
}
async collapseKeeps(label: string, value: string): Promise<void> {
const expand = this.button('展开').first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选');
}
await this.fillFilter(label, value);
const collapse = this.button('收起').first();
await expect(collapse, '收起按钮').toBeVisible();
await this.click(collapse, '收起');
await this.click(this.button('展开').first(), '再次展开');
await expect(this.textInput(label)).toHaveValue(value);
}
async expectInputCleared(label: string): Promise<void> {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await expect(input).toHaveValue('');
}
}
async clickTabAndMatchCount(name: string): Promise<number | null> {
const marked = await this.readTabCount(name);
await this.clickTab(name);
const total = await this.getTotalCount();
if (marked !== null) {
expect(total, `${name} Tab 计数应等于分页总数`).toBe(marked);
}
return marked;
}
async goToPage2(identityHeader: string): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const first = await this.cellText(await this.firstRow(), identityHeader);
const item = this.page.locator('.ant-pagination-item-2').first();
await this.click(item, '翻到第 2 页');
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe('2');
const next = await this.cellText(await this.firstRow(), identityHeader);
expect(next, '第 2 页首行应与第 1 页不同').not.toBe(first);
}
async jumpToPage(pageNum: number): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (n) => this.page.locator(`.ant-pagination-item-${n}`).first().isVisible().catch(() => false),
});
if (!(await this.locators.pageJumpInput.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无跳至输入框或仅 1 页');
}
await this.fill(this.locators.pageJumpInput, String(pageNum), '跳至指定页');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe(String(pageNum));
}
async expectPrevDisabled(): Promise<void> {
await expect(this.locators.prevPage, '第 1 页上一页应不可用').toHaveClass(/ant-pagination-disabled/);
}
rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async rowWithAction(name: string): Promise<Locator | null> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 15);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
if (await this.rowAction(row, name).isVisible().catch(() => false)) return row;
}
return null;
}
async skipIfNoAction(name: string, detail: string): Promise<Locator> {
const row = await this.rowWithAction(name);
if (!row) await this.skipWhenDataInsufficient(detail);
return row as Locator;
}
async skipIfTabEmpty(name: string): Promise<void> {
const marked = await this.readTabCount(name);
await this.clickTab(name);
const rows = await this.getDataRowCount();
if ((marked === 0 || marked === null) && rows === 0) {
await this.skipWhenDataInsufficient(`${name} Tab 无数据`);
}
}
async openPutFromRow(row: Locator, action: string): Promise<void> {
await this.assertBeforeBusinessAction(action);
await this.click(this.rowAction(row, action), action);
await expect(this.put(), `${action}应打开详情或编辑层`).toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
await this.assertPageHealthy(action);
}
async leaveWithoutCommit(): Promise<void> {
for (let i = 0; i < 4; i += 1) {
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-popconfirm:visible, .ant-modal:visible').last();
if (await confirm.isVisible().catch(() => false)) {
const text = (await confirm.innerText().catch(() => '')) ?? '';
const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first();
if (DANGER_CONFIRM.test(text)) {
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取消确认');
await this.waitSettle(200);
continue;
}
}
const dismiss = confirm.getByRole('button', { name: /取\s*消|关\s*闭/ }).first();
if (await dismiss.isVisible().catch(() => false)) {
await this.click(dismiss, '关闭弹层');
await this.waitSettle(200);
continue;
}
}
const put = this.page.locator('.put-page:visible').last();
if (!(await put.isVisible().catch(() => false))) return;
const cancel = put.getByRole('button', { name: /取\s*消/ }).first();
const back = put.getByRole('button', { name: /返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取消');
} else if (await back.isVisible().catch(() => false)) {
await this.click(back, '返回');
} else {
return;
}
await this.waitSettle(300);
}
await expect(this.page.locator('.put-page:visible'), '应离开详情或编辑层').toHaveCount(0);
}
async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/).first();
await expect(this.locators.formError.first().or(hint), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 });
const stayed =
(await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0;
expect(stayed, '拦截后页面不应关闭').toBeTruthy();
}
async clearChecks(): Promise<void> {
for (let i = 0; i < 8; i += 1) {
const checked = this.page.locator('.ant-table-tbody .ant-checkbox-checked').first();
if (!(await checked.isVisible().catch(() => false))) return;
await checked.click();
await this.waitSettle(100);
}
}
async checkFirstRow(): Promise<Locator> {
const row = await this.firstRow();
const box = row.locator('.ant-checkbox').first();
await this.click(box, '勾选首行');
return row;
}
/** 未勾选:按钮禁用,或点击后只出现提示 / 可取消的确认,不点确定。 */
async expectNeedsSelection(name: string): Promise<void> {
await this.clearChecks();
const total = await this.getTotalCount();
const button = this.button(name).first();
await expect(button, name).toBeVisible();
if (await button.isDisabled().catch(() => false)) return;
const className = (await button.getAttribute('class')) ?? '';
if (className.includes('ant-btn-disabled')) return;
await this.click(button, name);
const hint = this.page.getByText(/请选择|请勾选|至少选择|未选择/).first();
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
const hinted = await hint.isVisible().catch(() => false);
const confirmed = await confirm.isVisible().catch(() => false);
expect(hinted || confirmed, `${name}未勾选时应不可用或提示`).toBeTruthy();
if (confirmed) {
const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消');
}
expect(await this.getTotalCount(), '未勾选不应新增单据').toBe(total);
}
async clickToolbarThenCancel(name: string): Promise<void> {
await this.assertBeforeBusinessAction(name);
await this.clickButton(name);
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer, `${name}应打开操作层`).toBeVisible({ timeout: 10_000 });
await this.leaveWithoutCommit();
}
async openDetailSection(name: string): Promise<void> {
const target = this.put().getByRole('button', { name: new RegExp(name) }).or(this.put().getByText(name)).first();
await this.assertBeforeBusinessAction(name);
await this.click(target, name);
await this.waitSettle(300);
await this.assertUiReady(name);
await expect(this.put(), '详情层仍在').toBeVisible();
}
putInput(label: string): Locator {
return this.textInput(label, this.put());
}
async saveOnPut(): Promise<void> {
await this.assertBeforeBusinessAction('保存');
const save = this.button('保存', this.put()).first();
if (await save.isVisible().catch(() => false)) {
await this.click(save, '保存');
return;
}
await this.click(this.button('提交', this.put()).first(), '提交');
}
}
import { expect, type Page } from '@playwright/test';
import { admitData, uniqRemark } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 供货目录申请。提交与批量提交只取消,不确认。 */
export class AdmitSupplyPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.supplyPath,
pathPart: 'supplyApply/list',
menu: admitData.menuSupply,
forbidden: ['productApply/', 'productOrder/', 'accessKind/'],
});
}
async ready(): Promise<void> {
await this.expectOnPage();
}
async filterBySupplier(): Promise<void> {
await this.query();
const supplier = await this.sampleCell('进院供应商');
await this.chooseSelect('进院供应商', supplier);
await this.query();
await this.expectRowsContain('进院供应商', supplier);
}
async resetFilters(): Promise<void> {
await this.query();
const supplier = await this.sampleCell('进院供应商');
await this.chooseSelect('进院供应商', supplier);
await this.reset();
await this.expectListOrEmpty();
}
async mockEmpty() {
if (await this.textInput('变更单号').isVisible().catch(() => false)) {
await this.fillFilter('变更单号', admitData.mockOrder);
} else {
await this.fillFilter('产品名称', admitData.mockGoods);
}
await this.query();
return this.snapshotList();
}
async coreHeaders(): Promise<string[]> {
await this.query();
return this.headerTexts();
}
async switchPending(): Promise<void> {
await this.clickTabAndMatchCount('待提交');
await expect(this.tab('待提交')).toHaveClass(/ant-tabs-tab-active/);
}
async openAddFields(): Promise<void> {
await this.clickButton('新增');
await expect(this.put()).toBeVisible();
for (const label of ['进院供应商', '二级供应商', '业务模式', '是否默认', '备注']) {
await expect(this.put().getByText(label).first(), label).toBeVisible();
}
await this.leaveWithoutCommit();
}
async addCancel(): Promise<void> {
const total = await this.getTotalCount();
const remark = uniqRemark();
await this.clickButton('新增');
await this.fill(this.putInput('备注'), remark, '填写备注');
await this.leaveWithoutCommit();
expect(await this.getTotalCount(), '未提交总数不变').toBe(total);
await expect(this.page.getByText(remark)).toHaveCount(0);
}
async switchModeWithoutSubmit(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.selectFirstOption('业务模式', this.put());
const no = this.put().getByText('否', { exact: true }).first();
if (await no.isVisible().catch(() => false)) await this.click(no, '切换是否默认');
else await this.selectFirstOption('是否默认', this.put());
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async editBackfill(): Promise<void> {
await this.clickTab('待提交');
const row = await this.skipIfNoAction('编辑', '待提交无编辑');
const supplier = await this.cellText(row, '进院供应商');
await this.openPutFromRow(row, '编辑');
await expect(this.put()).toContainText(supplier.slice(0, 6));
await this.leaveWithoutCommit();
}
async editCancel(): Promise<void> {
await this.clickTab('待提交');
const row = await this.skipIfNoAction('编辑', '待提交无编辑');
await this.openPutFromRow(row, '编辑');
const input = this.putInput('备注');
const original = await input.inputValue().catch(() => '');
await this.fill(input, uniqRemark(), '临时备注');
await this.leaveWithoutCommit();
await this.clickTab('待提交');
const again = await this.skipIfNoAction('编辑', '待提交无编辑');
await this.openPutFromRow(again, '编辑');
await expect(this.putInput('备注')).toHaveValue(original);
await this.leaveWithoutCommit();
}
async emptySupplierBlocked(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.clickButton('提交', this.put());
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async noProductBlocked(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.selectFirstOption('进院供应商', this.put());
await this.selectFirstOption('二级供应商', this.put());
await this.selectFirstOption('业务模式', this.put());
await this.clickButton('提交', this.put());
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async batchNeedsSelection(): Promise<void> {
await this.expectNeedsSelection('批量提交');
}
async batchCancel(): Promise<void> {
await this.skipIfTabEmpty('待提交');
const row = await this.checkFirstRow();
const order = await this.tryCell(row, '申请单号');
await this.clickButton('批量提交');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible, .put-page:visible').last();
await expect(confirm).toBeVisible();
const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first();
await this.click(cancel, '取消批量提交');
await this.clickTab('待提交');
if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible();
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const order = await this.tryCell(row, '申请单号');
await this.openPutFromRow(row, '详情');
if (order) await expect(this.put()).toContainText(order.slice(0, 8));
await expect(this.put().getByText('审批进程').first()).toBeVisible();
await this.leaveWithoutCommit();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'select', value: '样本' },
{ label: '供应商', kind: 'select', value: '样本' },
{ label: '变更单号', kind: 'text', value: '样本' },
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 准入任务分配。分配只取消,不确认。 */
export class AdmitTaskPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.taskPath,
pathPart: 'accessKind/list',
menu: admitData.menuTask,
forbidden: ['productApply/', 'productOrder/', 'supplyApply/'],
});
}
async ready(): Promise<void> {
await this.expectOnPage();
}
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
async switchTabs(): Promise<void> {
await this.clickTabAndMatchCount('待分类');
await this.clickTabAndMatchCount('已分类');
await this.clickTab('待分类');
await expect(this.tab('待分类')).toHaveClass(/ant-tabs-tab-active/);
}
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
}
async mockEmpty() {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
return this.snapshotList();
}
async coreHeaders(): Promise<string[]> {
await this.clickTab('待分类');
return this.headerTexts();
}
async goPage2(): Promise<void> {
await this.query();
await this.goToPage2('产品名称');
}
async jumpPage2(): Promise<void> {
await this.query();
await this.jumpToPage(2);
}
async prevDisabled(): Promise<void> {
await this.query();
await this.expectPrevDisabled();
}
async assignCancel(): Promise<void> {
await this.clickTab('待分类');
const before = await this.readTabCount('待分类');
const row = await this.skipIfNoAction('分配类型', '待分类无分配类型');
const name = await this.cellText(row, '产品名称');
await this.click(this.rowAction(row, '分配类型'), '分配类型');
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer, '分配层应打开').toBeVisible();
await this.leaveWithoutCommit();
await this.clickTab('待分类');
await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible();
if (before !== null) expect(await this.readTabCount('待分类')).toBe(before);
}
async batchNeedsSelection(): Promise<void> {
await this.clickTab('待分类');
await this.expectNeedsSelection('批量分配');
}
async batchCancel(): Promise<void> {
await this.clickTab('待分类');
if ((await this.getDataRowCount()) === 0) await this.skipWhenDataInsufficient('待分类无数据');
const before = await this.readTabCount('待分类');
const row = await this.checkFirstRow();
const name = await this.cellText(row, '产品名称');
await this.clickButton('批量分配');
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer).toBeVisible();
await this.leaveWithoutCommit();
await this.clickTab('待分类');
await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible();
if (before !== null) expect(await this.readTabCount('待分类')).toBe(before);
}
async classifiedNoAssign(): Promise<void> {
await this.skipIfTabEmpty('已分类');
const assign = (await this.listRows()).getByRole('button', { name: /分配类型/ });
if ((await assign.count()) === 0) return;
const before = await this.readTabCount('已分类');
await this.click(assign.first(), '已分类分配类型');
const hint = this.page.getByText(/不可|不允许|不能|已分类/).first();
const layer = this.page.locator('.ant-modal:visible, .put-page:visible').last();
expect(
(await hint.isVisible().catch(() => false)) || (await layer.isVisible().catch(() => false)),
'已分类再分配应被拒绝或需取消',
).toBeTruthy();
await this.leaveWithoutCommit();
if (before !== null) expect(await this.readTabCount('已分类')).toBe(before);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '新增日期', kind: 'dateRange', value: '样本' },
{ label: '操作日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'text', value: '样本' },
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '注册证号', kind: 'text', value: '样本' },
{ label: '操作人', kind: 'text', value: '样本' },
{ label: '器械注册人', kind: 'text', value: '样本' },
{ label: '耗材类型', kind: 'select', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
{ label: '准入类型', kind: 'select', value: '样本' },
{ label: '准入状态', kind: 'select', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { basicHospitalData, uniqCampusName } from '../../data/basic-hospital.data';
import { BasicBranchLocators } from '../../locators/spd/basic-branch.locator';
import { BasicHospitalSharedPage } from './basic-shared.page';
export class BasicBranchPage extends BasicHospitalSharedPage {
readonly locators: BasicBranchLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicBranchLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.branchPath);
}
async resetSessionForReuse(): Promise<void> {
await this.closeDialogIfOpen();
if (await this.locators.campusNameInput.isVisible().catch(() => false)) {
await this.locators.campusNameInput.fill('');
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.waitForTableReady();
}
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/paltform/area');
await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/info', '/balance/month']);
await expect(this.locators.addBtn).toBeVisible();
await expect(this.locators.campusNameInput).toBeVisible();
await expect(this.shared.tableHeader.first()).toBeVisible();
await expect(this.page.getByRole('button', { name: /查\s*询/ })).toHaveCount(0);
await expect(this.page.getByRole('button', { name: /重\s*置/ })).toHaveCount(0);
}
async extractSample(): Promise<{ sampleCampusName: string; sampleCampusId: string; sampleCampusAddress: string }> {
const row = await this.firstRow();
return {
sampleCampusName: await this.cellText(row, '院区名称'),
sampleCampusId: await this.cellText(row, '院区编号'),
sampleCampusAddress: await this.cellText(row, '院区地址'),
};
}
async searchByName(name: string): Promise<void> {
await this.fill(this.locators.campusNameInput, name, '填写院区名称');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
async expectNameResults(keyword: string): Promise<void> {
const count = await this.getDataRowCount();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '院区名称');
expect(text).toContain(keyword.slice(0, Math.min(keyword.length, 6)));
}
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['序号', '院区编号', '院区名称', '院区地址', '状态', '启用云仓', '操作']);
await expect((await this.firstRow()).getByRole('button', { name: /编\s*辑/ })).toBeVisible();
}
async clearSearch(): Promise<void> {
const filtered = await this.getTotalCount();
await this.locators.campusNameInput.fill('');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const restored = await this.getTotalCount();
expect(restored, '清空后条数应不少于过滤后').toBeGreaterThanOrEqual(filtered);
}
async expectMockEmpty(): Promise<void> {
const rows = await this.getDataRowCount();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(rows === 0 || empty).toBeTruthy();
expect(await this.getTotalCount()).toBe(0);
}
async openAddDialog(): Promise<void> {
await this.click(this.locators.addBtn, '新 增');
await expect(this.shared.dialog).toContainText('新增院区');
await expect(this.locators.addName).toBeVisible();
await expect(this.dialogFormItem('所在地区')).toBeVisible();
await expect(this.locators.addAddress).toBeVisible();
await expect(this.shared.dialog.getByText('主院区')).toBeVisible();
await expect(this.shared.dialog.getByText('停用')).toBeVisible();
await expect(this.shared.dialog.getByText('启用').first()).toBeVisible();
await expect(this.locators.editId).toHaveCount(0);
await expect(this.shared.dialog.getByText('启用云仓')).toHaveCount(0);
}
async addAndCancel(): Promise<void> {
const total = await this.getTotalCount();
const name = uniqCampusName();
await this.openAddDialog();
await this.fill(this.locators.addName, name, '填写院区名称');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await expect(this.shared.dialog).toBeHidden({ timeout: 8_000 });
expect(await this.getTotalCount()).toBe(total);
await this.searchByName(name);
await this.expectMockEmpty();
await this.locators.campusNameInput.fill('');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
async addSuccessAndCleanup(): Promise<void> {
const name = uniqCampusName();
await this.openAddDialog();
await this.fill(this.locators.addName, name, '填写院区名称');
await this.pickCascaderFirstPath();
await this.fill(this.locators.addAddress, '自动化测试地址', '填写详细地址');
await this.dialogFormItem('主院区').getByText('否', { exact: true }).click();
await this.dialogFormItem('状态').getByText('启用', { exact: true }).click();
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await expect(this.shared.dialog).toBeHidden({ timeout: 15_000 });
await this.searchByName(name);
expect(await this.cellText(await this.firstRow(), '院区名称')).toContain(name);
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toBeVisible();
const disable = this.dialogFormItem('状态').getByText(/禁用|停用/).first();
if (await disable.isVisible().catch(() => false)) {
await disable.click();
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '停用测试院区');
await this.waitSpinGone();
} else {
await this.closeDialogIfOpen();
}
}
async addEmptyName(): Promise<void> {
await this.openAddDialog();
await this.pickCascaderFirstPath();
await this.fill(this.locators.addAddress, basicHospitalData.mockAddr, '填写地址');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async addEmptyRegion(): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.addName, basicHospitalData.mockCampusNoRegion, '填写名称');
await this.fill(this.locators.addAddress, basicHospitalData.mockAddr, '填写地址');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async addEmptyAddress(): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.addName, basicHospitalData.mockCampusNoAddr, '填写名称');
await this.pickCascaderFirstPath();
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async openEditAndExpectBackfill(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toContainText('编辑院区');
expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId);
expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName);
expect(await this.locators.editAddress.inputValue()).toContain(sample.sampleCampusAddress.slice(0, 4));
await expect(this.shared.dialog.getByText('启用云仓')).toBeVisible();
await this.closeDialogIfOpen();
}
async expectIdNotChangeable(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.locators.editId).toBeVisible();
expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId);
const disabled = await this.locators.editId.isDisabled().catch(() => false);
const readonly = await this.locators.editId.getAttribute('readonly');
if (!disabled && readonly === null) {
await this.fill(this.locators.editId, basicHospitalData.mockCampusId, '尝试改编号');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.waitSpinGone();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId);
}
await this.closeDialogIfOpen();
}
async editAndCancel(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await this.fill(this.locators.editName, uniqCampusName(), '改名称');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName);
await this.closeDialogIfOpen();
}
async expectEditStatusLabels(): Promise<void> {
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog.getByText('禁用')).toBeVisible();
await expect(this.shared.dialog.getByText('启用').first()).toBeVisible();
await expect(this.dialogFormItem('状态').getByText('停用')).toHaveCount(0);
await this.closeDialogIfOpen();
}
async expectCloudWarehouse(): Promise<void> {
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.dialogFormItem('启用云仓').getByText('是', { exact: true })).toBeVisible();
await expect(this.dialogFormItem('启用云仓').getByText('否', { exact: true })).toBeVisible();
await this.closeDialogIfOpen();
}
async editClearRequired(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await this.locators.editName.fill('');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName);
await this.closeDialogIfOpen();
}
}
export const BASIC_BRANCH_PATH = basicHospitalData.branchPath;
import { expect, type Locator, type Page, test } from '@playwright/test';
import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicFinanceMonthLocators } from '../../locators/spd/basic-finance-month.locator';
import { BasicHospitalSharedPage } from './basic-shared.page';
export type FinanceSample = {
sampleFinanceMonth: string;
sampleStartDate: string;
sampleEndDate: string;
sampleAutoSettleDate: string;
};
export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
readonly locators: BasicFinanceMonthLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicFinanceMonthLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.financeMonthPath);
}
async search(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady();
}
async resetSessionForReuse(options?: { search?: boolean }): Promise<void> {
if (await this.locators.cancelBtn.isVisible().catch(() => false)) {
await this.click(this.locators.cancelBtn, '取消行内编辑');
}
if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.assertBeforeBusinessAction('重置');
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
if (options?.search) await this.search();
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/balance/month');
await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/info', '/paltform/area']);
await expect(this.locators.settleMonth).toBeVisible();
await expect(this.locators.searchBtn).toBeVisible();
await expect(this.locators.resetBtn).toBeVisible();
await expect(this.shared.tableHeader.first()).toBeVisible();
}
currentMonthRow(): Locator {
return this.dataRows().filter({ hasText: '当前月份' }).first();
}
historyRows(): Locator {
return this.dataRows().filter({ hasText: '历史月份' });
}
async extractSample(): Promise<FinanceSample> {
await this.search();
const row = (await this.currentMonthRow().isVisible().catch(() => false))
? this.currentMonthRow()
: await this.firstRow();
return {
sampleFinanceMonth: await this.cellText(row, '财务月份'),
sampleStartDate: await this.cellText(row, '开始日期'),
sampleEndDate: await this.cellText(row, '结束日期'),
sampleAutoSettleDate: await this.cellText(row, '自动结算日期'),
};
}
async queryByMonth(month: string): Promise<void> {
await this.click(this.locators.settleMonth, '打开财务月份');
const panel = this.page.locator('.ant-picker-dropdown:visible').last();
const opt = panel.getByText(month, { exact: true }).first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择 ${month}`);
} else {
await this.locators.settleMonth.fill(month);
await this.page.keyboard.press('Enter');
}
await this.search();
}
async expectMonthResults(month: string): Promise<void> {
const count = await this.getDataRowCount();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '财务月份');
expect(text).toContain(month);
}
}
async resetFilters(): Promise<void> {
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['财务月份', '开始日期', '结束日期', '自动结算日期', '状态', '操作']);
}
async expectOnlyCurrentEditable(): Promise<void> {
const current = this.currentMonthRow();
await expect(current, '应存在当前月份行').toBeVisible();
await expect(current.getByRole('button', { name: /修\s*改/ })).toBeVisible();
const history = this.historyRows();
const historyCount = await history.count();
for (let i = 0; i < historyCount; i += 1) {
await expect(history.nth(i).getByRole('button', { name: /修\s*改/ })).toHaveCount(0);
}
}
async expectAtMostOneCurrent(): Promise<void> {
const count = await this.dataRows().filter({ hasText: '当前月份' }).count();
expect(count, '当前月份至多一行').toBeLessThanOrEqual(1);
}
async expectHistoryNotEditable(): Promise<void> {
const history = this.historyRows();
if ((await history.count()) === 0) {
test.skip(true, '无历史月份');
}
await history.first().click({ trial: true }).catch(() => undefined);
await expect(this.locators.saveBtn).toBeHidden();
await expect(this.locators.autoSettleDateInput).toBeHidden();
}
async enterInlineEdit(): Promise<void> {
const row = this.currentMonthRow();
await expect(row).toBeVisible();
await this.click(row.getByRole('button', { name: /修\s*改/ }), '修 改');
await expect(this.locators.saveBtn).toBeVisible();
await expect(this.locators.cancelBtn).toBeVisible();
await expect(this.locators.autoSettleDateInput).toBeVisible();
}
async expectStartEndReadonly(): Promise<void> {
await this.enterInlineEdit();
const row = this.currentMonthRow();
const startInput = row.locator('td').nth(await this.headerIndex('开始日期')).locator('input');
const endInput = row.locator('td').nth(await this.headerIndex('结束日期')).locator('input');
if (await startInput.count()) await expect(startInput.first()).toBeDisabled();
if (await endInput.count()) await expect(endInput.first()).toBeDisabled();
await expect(this.locators.autoSettleDateInput).toBeEditable();
await this.click(this.locators.cancelBtn, '取 消');
}
async pickAltDateInRange(start: string, end: string, current: string): Promise<string> {
const startDay = Number(start.slice(-2));
const endDay = Number(end.slice(-2));
const curDay = Number(current.slice(-2));
let next = curDay === startDay ? Math.min(endDay, startDay + 1) : startDay;
if (next === curDay) next = Math.min(endDay, curDay + 1);
return `${start.slice(0, 8)}${String(next).padStart(2, '0')}`;
}
async fillAutoSettle(date: string): Promise<void> {
await this.locators.autoSettleDateInput.click();
await this.locators.autoSettleDateInput.fill(date);
await this.page.keyboard.press('Enter');
await this.waitSettle(200);
}
async cancelWithoutSave(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
const alt = await this.pickAltDateInRange(sample.sampleStartDate, sample.sampleEndDate, sample.sampleAutoSettleDate);
await this.fillAutoSettle(alt);
await this.click(this.locators.cancelBtn, '取 消');
const shown = await this.cellText(this.currentMonthRow(), '自动结算日期');
expect(shown).toContain(sample.sampleAutoSettleDate);
}
async saveAndRestore(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
const alt = await this.pickAltDateInRange(sample.sampleStartDate, sample.sampleEndDate, sample.sampleAutoSettleDate);
await this.fillAutoSettle(alt);
await this.click(this.locators.saveBtn, '保 存');
await this.waitSpinGone();
await this.search();
expect(await this.cellText(this.currentMonthRow(), '自动结算日期')).toContain(alt);
await this.enterInlineEdit();
await this.fillAutoSettle(sample.sampleAutoSettleDate);
await this.click(this.locators.saveBtn, '恢复自动结算日');
await this.waitSpinGone();
}
async saveEmptyDate(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
await this.locators.autoSettleDateInput.fill('');
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.click(this.locators.saveBtn, '保 存');
await this.expectFormError();
const shown = await this.cellText(this.currentMonthRow(), '自动结算日期');
expect(shown.includes(sample.sampleAutoSettleDate) || shown.length > 0).toBeTruthy();
if (await this.locators.cancelBtn.isVisible().catch(() => false)) {
await this.click(this.locators.cancelBtn, '取 消');
}
}
async saveOutOfRange(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
try {
await this.fillAutoSettle(basicHospitalData.mockOutOfRangeDate);
await this.click(this.locators.saveBtn, '保 存');
await this.expectFormError();
const shown = await this.cellText(this.currentMonthRow(), '自动结算日期');
expect(shown).not.toContain('1999');
} catch {
test.skip(true, '日期控件无法选出区间外日期');
} finally {
if (await this.locators.cancelBtn.isVisible().catch(() => false)) {
await this.click(this.locators.cancelBtn, '取 消');
}
}
expect(await this.cellText(this.currentMonthRow(), '自动结算日期')).toContain(sample.sampleAutoSettleDate);
}
}
export const BASIC_FINANCE_MONTH_PATH = basicHospitalData.financeMonthPath;
import { expect, type Page, test } from '@playwright/test';
import { basicHospitalData, uniqLinkman } from '../../data/basic-hospital.data';
import { BasicHospitalInfoLocators } from '../../locators/spd/basic-hospital-info.locator';
import { BasicHospitalSharedPage } from './basic-shared.page';
export class BasicHospitalInfoPage extends BasicHospitalSharedPage {
readonly locators: BasicHospitalInfoLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicHospitalInfoLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.hospitalInfoPath);
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/paltform/info');
await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/area', '/balance/month']);
await expect(this.locators.editBtn, '查看态编辑').toBeVisible();
await expect(this.page.getByText('医院编码').first()).toBeVisible();
await expect(this.page.getByText('医院名称').first()).toBeVisible();
await expect(this.page.getByText(/地址/).first()).toBeVisible();
}
async expectViewReadonly(): Promise<void> {
await expect(this.locators.saveBtn).toBeHidden();
await expect(this.locators.cancelBtn).toBeHidden();
await this.expectSystemFieldsReadonly();
}
async expectSystemFieldsReadonly(): Promise<void> {
for (const label of ['医院编码', '医院名称']) {
const input = this.formItem(label).locator('input:not([type="hidden"])');
if (await input.count()) {
await expect(input.first(), `${label} 应只读`).toBeDisabled();
}
}
}
async previewIfPresent(): Promise<void> {
const preview = this.locators.previewLink.first();
if (!(await preview.isVisible().catch(() => false))) {
test.skip(true, '均无预览入口');
}
await this.click(preview, '预览文件');
const previewLayer = this.page.locator('.ant-image-preview, .ant-modal, .ant-image').first();
await expect(previewLayer.or(this.page.locator('img').first()), '预览应打开').toBeVisible({ timeout: 8_000 });
await this.page.keyboard.press('Escape').catch(() => undefined);
}
async enterEdit(): Promise<void> {
await this.click(this.locators.editBtn, '编 辑');
await expect(this.locators.saveBtn).toBeVisible();
await expect(this.locators.cancelBtn).toBeVisible();
await expect(this.locators.linkman).toBeEditable();
await expect(this.locators.contactWay).toBeEditable();
await expect(this.locators.shortName).toBeEditable();
}
async readLinkman(): Promise<string> {
if (await this.locators.linkman.isVisible().catch(() => false)) {
return this.locators.linkman.inputValue();
}
return ((await this.formItem('联系人').innerText()) ?? '').replace(/联系人\*?/, '').trim();
}
async cancelWithoutSave(): Promise<void> {
await this.enterEdit();
const original = await this.readLinkman();
await this.fill(this.locators.linkman, uniqLinkman(), '改联系人');
await this.click(this.locators.cancelBtn, '取 消');
await expect(this.locators.saveBtn).toBeHidden();
const shown = await this.readLinkman();
expect(shown).toContain(original);
}
async saveAndRestore(): Promise<void> {
await this.enterEdit();
const original = await this.readLinkman();
const next = uniqLinkman();
await this.fill(this.locators.linkman, next, '改联系人');
await this.click(this.locators.saveBtn, '保 存');
await this.waitSpinGone();
await this.open();
await this.enterEdit();
expect(await this.readLinkman()).toBe(next);
await this.fill(this.locators.linkman, original, '恢复联系人');
await this.click(this.locators.saveBtn, '保 存恢复');
await this.waitSpinGone();
}
async saveEmptyField(field: 'linkman' | 'contactWay' | 'shortName'): Promise<void> {
await this.enterEdit();
const locator = this.locators[field];
await locator.fill('');
await this.click(this.locators.saveBtn, '保 存');
await this.expectFormError();
await expect(this.locators.saveBtn, '仍停留编辑态').toBeVisible();
await this.click(this.locators.cancelBtn, '取 消');
}
async expectUploadInputs(): Promise<void> {
await this.enterEdit();
await expect(this.locators.loginBgUpload).toHaveCount(1);
await expect(this.page.locator('input[type="file"]')).toHaveCount(2);
await this.click(this.locators.cancelBtn, '取 消');
}
}
export const BASIC_HOSPITAL_INFO_PATH = basicHospitalData.hospitalInfoPath;
import { expect, type Page } from '@playwright/test';
import {
basicHospitalData,
uniqNoticeTitle,
} from '../../data/basic-hospital.data';
import { BasicNoticeLocators } from '../../locators/spd/basic-notice.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { BasicHospitalSharedPage } from './basic-shared.page';
export class BasicNoticePage extends BasicHospitalSharedPage {
readonly locators: BasicNoticeLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicNoticeLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.noticePath);
}
async search(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady();
}
async resetSessionForReuse(options?: { search?: boolean }): Promise<void> {
await this.closeDialogIfOpen();
if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.assertBeforeBusinessAction('重置');
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
if (options?.search) await this.search();
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/paltform/notice');
await this.expectNotOnSiblingPages(['/paltform/info', '/paltform/area', '/balance/month']);
await expect(this.locators.noticeTitle, '筛选区公告标题').toBeVisible();
await expect(this.locators.addBtn, '新增按钮').toBeVisible();
await expect(this.shared.tableHeader.first(), '列表表头').toBeVisible();
}
async extractSample(): Promise<{ sampleNoticeTitle: string; samplePublishScope: string }> {
await this.search();
const row = await this.firstRow();
return {
sampleNoticeTitle: await this.cellText(row, '公告标题'),
samplePublishScope: await this.cellText(row, '发布范围'),
};
}
async queryByTitle(title: string): Promise<void> {
await this.fill(this.locators.noticeTitle, title, '填写公告标题');
await this.search();
}
async expectTitleResultsMatch(keyword: string): Promise<void> {
const count = await this.getDataRowCount();
expect(count, '标题查询应有结果').toBeGreaterThan(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '公告标题');
expect(text, `第 ${i + 1} 行标题应包含关键字`).toContain(keyword.slice(0, Math.min(keyword.length, 8)));
}
}
async queryByScope(scope: string): Promise<void> {
await this.selectDropdown(this.locators.publishScopeSelect, scope);
await this.search();
}
async expectScopeResultsMatch(scope: string): Promise<void> {
if (scope === '全部') return;
const count = await this.getDataRowCount();
expect(count, '范围查询应有结果或空态可接受').toBeGreaterThanOrEqual(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '发布范围');
expect(text, `第 ${i + 1} 行发布范围`).toContain(scope);
}
}
async queryByTitleAndScope(title: string, scope: string): Promise<void> {
await this.fill(this.locators.noticeTitle, title, '填写公告标题');
if (scope && scope !== '全部') {
await this.selectDropdown(this.locators.publishScopeSelect, scope);
}
await this.search();
}
async resetFilters(): Promise<void> {
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
await expect(this.locators.noticeTitle).toHaveValue('');
}
async expectMockEmpty(): Promise<void> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(rows === 0 || empty, '无匹配应空态').toBeTruthy();
expect(total === 0 || rows === 0, '分页总数应为 0').toBeTruthy();
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['公告编号', '公告标题', '发布范围', '发布科室', '发布人', '操作']);
const row = await this.firstRow();
await expect(row.getByRole('button', { name: /编\s*辑/ })).toBeVisible();
await expect(row.getByRole('button', { name: /删\s*除/ })).toBeVisible();
}
async goToPage2(): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: (n) => this.hasPaginationItem(n),
});
const firstNo = await this.cellText(await this.firstRow(), '公告编号');
await this.clickPaginationPage(2);
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe('2');
const nextNo = await this.cellText(await this.firstRow(), '公告编号');
expect(nextNo, '第 2 页首行应与第 1 页不同').not.toBe(firstNo);
}
async jumpToPage(pageNum: number): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: (n) => this.hasPaginationItem(n),
});
if (!(await this.locators.pageJumpInput.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无 jumper 或仅 1 页');
}
await this.fill(this.locators.pageJumpInput, String(pageNum), '跳至指定页');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe(String(pageNum));
}
async openAddDialog(): Promise<void> {
await this.click(this.locators.addBtn, '新 增');
await expect(this.shared.dialog).toBeVisible();
await expect(this.shared.dialog).toContainText('新增公告');
await expect(this.locators.modalTitleInput).toBeVisible();
await expect(this.shared.dialog.getByText('发布范围')).toBeVisible();
await expect(this.shared.dialog.getByText('置顶')).toBeVisible();
await expect(this.shared.dialog.getByText('截止日期')).toBeVisible();
await expect(this.locators.richTextEditor).toBeVisible();
await expect(this.shared.dialog.getByRole('button', { name: /取\s*消/ })).toBeVisible();
await expect(this.shared.dialog.getByRole('button', { name: /发\s*布/ })).toBeVisible();
}
async addAndCancel(title: string): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.modalTitleInput, title, '填写新增标题');
await this.locators.richTextEditor.fill('自动化取消用例正文');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await expect(this.shared.dialog).toBeHidden({ timeout: 8_000 });
await this.queryByTitle(title);
await this.expectMockEmpty();
}
async publishAndCleanup(): Promise<void> {
const title = uniqNoticeTitle();
await this.openAddDialog();
await this.fill(this.locators.modalTitleInput, title, '填写新增标题');
await this.shared.dialog.getByText('中心库', { exact: true }).click();
await this.locators.richTextEditor.fill('自动化发布用例正文');
await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布');
await expect(this.shared.dialog).toBeHidden({ timeout: 15_000 });
await this.queryByTitle(title);
const row = await this.firstRow();
expect(await this.cellText(row, '公告标题')).toContain(title);
expect(await this.cellText(row, '发布范围')).toContain('中心库');
await row.getByRole('button', { name: /删\s*除/ }).click();
const confirm = this.page.getByRole('button', { name: /确\s*定/ }).last();
if (await confirm.isVisible().catch(() => false)) {
await this.click(confirm, '确定删除测试公告');
}
await this.waitForTableReady();
}
async publishWithEmptyTitle(): Promise<void> {
await this.openAddDialog();
await this.locators.richTextEditor.fill(basicHospitalData.mockNoticeBody);
await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async publishWithEmptyContent(): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.modalTitleInput, basicHospitalData.mockEmptyContentTitle, '填写 mock 标题');
await this.locators.richTextEditor.fill('');
await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async openEditAndExpectBackfill(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toContainText('编辑公告');
const value = await this.locators.modalTitleInput.inputValue();
expect(value.replace(/\s/g, '')).toBe(sample.sampleNoticeTitle.replace(/\s/g, ''));
await this.closeDialogIfOpen();
}
async editAndCancel(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toBeVisible();
const uniq = uniqNoticeTitle();
await this.fill(this.locators.modalTitleInput, uniq, '改标题');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.modalTitleInput.inputValue()).toBe(sample.sampleNoticeTitle);
await this.closeDialogIfOpen();
}
async deleteConfirmAndCancel(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /删\s*除/ }).click();
const box = this.page.locator('.ant-modal, .ant-popconfirm, .ant-modal-confirm').filter({ hasText: /提示/ }).last();
await expect(box).toBeVisible();
await expect(box.getByRole('button', { name: /取\s*消/ })).toBeVisible();
await expect(box.getByRole('button', { name: /确\s*定/ })).toBeVisible();
await this.click(box.getByRole('button', { name: /取\s*消/ }), '取消删除');
await this.queryByTitle(sample.sampleNoticeTitle);
expect(await this.getDataRowCount()).toBeGreaterThan(0);
}
}
export const BASIC_NOTICE_PATH = basicHospitalData.noticePath;
import { expect, type Locator, type Page } from '@playwright/test';
import { userData } from '../../data/user.data';
import { BasicRoleLocators } from '../../locators/spd/basic-role.locator';
import { BasicUserSharedPage } from './basic-user-shared.page';
/** 角色管理:左右分栏 + 权限树;断言在 tests/spd/basic-role.spec.ts。 */
export class BasicRolePage extends BasicUserSharedPage {
override readonly locators: BasicRoleLocators;
constructor(page: Page) {
super(page, {
path: userData.rolePath,
pathPart: '/basic/user/platform/role',
menu: userData.menuRole,
forbidden: ['personCom', '/platform/person'],
});
this.locators = new BasicRoleLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.saveBtn.or(this.locators.roleTree).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
leftRoleItems(): Locator {
return this.page.locator(
'.ant-layout-sider .ant-table-tbody tr.ant-table-row, .ant-layout-sider .ant-list-item, .ant-layout-sider .ant-menu-item, [class*="role"] .ant-table-row',
);
}
async leftRoleNames(): Promise<string[]> {
const items = this.leftRoleItems();
const count = await items.count();
if (count > 0) {
const names: string[] = [];
for (let i = 0; i < Math.min(count, 30); i += 1) {
const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim();
if (text) names.push(text);
}
if (names.length) return names;
}
const fallback = this.page.locator('.ant-table-tbody tr.ant-table-row td').first();
if (await fallback.isVisible().catch(() => false)) {
return (await this.page.locator('.ant-table-tbody tr.ant-table-row td:first-child').allTextContents())
.map((t) => t.trim())
.filter(Boolean);
}
return [];
}
async sampleRoleName(): Promise<string> {
const names = await this.leftRoleNames();
const found = names.find((n) => n && n !== '角色');
if (!found) {
await this.skipWhenDataInsufficient('左栏无角色');
return '';
}
return found;
}
leftRoleListScrollHost(): Locator {
const sider = this.locators.roleSider;
return sider
.locator('.ant-table-body')
.first()
.or(sider.locator('.rc-virtual-list-holder').first())
.or(sider.locator('.ant-list').first());
}
async assertLeftRoleListVerticalScroll(): Promise<void> {
const first = this.leftRoleItems().first();
await expect(first, '左栏应有角色行').toBeVisible();
await this.assertVerticalScrollRegion(
this.leftRoleListScrollHost(),
first,
'数据不足:左栏列表高度未溢出,无法验证滚动',
'左栏列表滚动后',
);
}
roleTreeScrollHost(): Locator {
return this.locators.roleTree
.locator('.ant-tree-list-holder')
.first()
.or(this.locators.roleTree.locator('.ant-tree-list').first())
.or(this.locators.roleTree);
}
async assertVerticalScrollRegion(
host: Locator,
walkAnchor: Locator,
skipDetail: string,
healthContext: string,
): Promise<void> {
const hostScrollable = await host
.evaluate((el) => el.scrollHeight > el.clientHeight + 4)
.catch(() => false);
if (hostScrollable) {
const top0 = await host.evaluate((el) => el.scrollTop);
await host.evaluate((el) => {
el.scrollTop = el.scrollHeight;
});
await this.waitSettle(250);
const topDown = await host.evaluate((el) => el.scrollTop);
expect(topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(top0);
const box = await host.boundingBox();
if (box) {
await this.page.mouse.move(box.x + box.width / 2, box.y + Math.min(box.height / 2, 120));
await this.page.mouse.wheel(0, -600);
await this.waitSettle(250);
} else {
await host.evaluate((el) => {
el.scrollTop = 0;
});
}
const topUp = await host.evaluate((el) => el.scrollTop);
expect(topUp, '向上滑动后应离开底部').toBeLessThan(topDown);
await host.evaluate((el) => {
el.scrollTop = 0;
});
await this.assertUiReady(healthContext);
return;
}
const walked = await walkAnchor.evaluate((row) => {
let el: HTMLElement | null = row as HTMLElement;
while (el) {
if (el.scrollHeight > el.clientHeight + 4) {
const top0 = el.scrollTop;
el.scrollTop = el.scrollHeight;
const topDown = el.scrollTop;
el.scrollTop = 0;
const topUp = el.scrollTop;
return { ok: true as const, top0, topDown, topUp };
}
el = el.parentElement;
}
return { ok: false as const };
});
if (!walked.ok) {
await this.skipWhenDataInsufficient(skipDetail);
return;
}
expect(walked.topDown, '向下滚动后 scrollTop 应增加').toBeGreaterThan(walked.top0);
expect(walked.topUp, '向上滚回后应回到顶部').toBeLessThanOrEqual(4);
await this.assertUiReady(healthContext);
}
async assertRoleTreeVerticalScroll(): Promise<void> {
const name = await this.sampleRoleName();
await this.selectRole(name);
await expect(this.locators.roleTree, '权限树').toBeVisible();
await this.expandTreeNode('基础设置');
const firstNode = this.locators.roleTree.locator('.ant-tree-treenode').first();
await expect(firstNode, '权限树应有节点').toBeVisible();
await this.assertVerticalScrollRegion(
this.roleTreeScrollHost(),
firstNode,
'数据不足:右侧权限树高度未溢出,无法验证滚动',
'右侧权限树滚动后',
);
}
async selectRole(name: string): Promise<void> {
const item = this.leftRoleItems().filter({ hasText: name }).first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, `选中角色 ${name}`);
} else {
await this.click(this.page.getByText(name, { exact: true }).first(), `选中角色 ${name}`);
}
await this.waitSpinGone();
await this.waitSettle(400);
}
async searchRole(keyword: string): Promise<void> {
const input = this.locators.roleSearch;
if (!(await input.isVisible().catch(() => false))) {
const fallback = this.page.locator('input').first();
await this.fill(fallback, keyword, '角色名称搜索');
} else {
await this.fill(input, keyword, '角色名称搜索');
}
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.waitSettle(400);
}
async clearRoleSearch(): Promise<void> {
const input = this.locators.roleSearch.or(this.page.locator('input').first());
await input.fill('');
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.waitSettle(400);
}
async isRoleSelected(name: string): Promise<boolean> {
const item = this.leftRoleItems().filter({ hasText: name }).first();
const cls = ((await item.getAttribute('class').catch(() => '')) ?? '').toLowerCase();
const aria = (await item.getAttribute('aria-selected').catch(() => '')) ?? '';
if (/selected|active/.test(cls) || aria === 'true') return true;
const selected = this.page.locator('.ant-table-row-selected, .ant-menu-item-selected, [aria-selected="true"]');
return (await selected.filter({ hasText: name }).count()) > 0;
}
async checkedSignature(): Promise<string> {
const nodes = this.locators.roleTree.locator('.ant-tree-treenode');
const limit = Math.min(await nodes.count(), 40);
const parts: string[] = [];
for (let i = 0; i < limit; i += 1) {
const title = ((await nodes.nth(i).locator('.ant-tree-title').innerText().catch(() => '')) ?? '').trim();
if (!title) continue;
const checked = (await nodes.nth(i).locator('.ant-tree-checkbox-checked').count()) > 0;
parts.push(`${title}:${checked ? 1 : 0}`);
}
return parts.join('|');
}
treeNodeRow(title: string): Locator {
return this.locators.roleTree
.locator('.ant-tree-treenode')
.filter({ has: this.page.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }) })
.first();
}
async isTreeNodeExpanded(title: string): Promise<boolean> {
const switcher = this.treeNodeRow(title).locator('.ant-tree-switcher').first();
if (!(await switcher.isVisible().catch(() => false))) return false;
return switcher.evaluate((el) => el.classList.contains('ant-tree-switcher_open')).catch(() => false);
}
async expandTreeNode(title: string): Promise<void> {
const node = this.treeNodeRow(title);
if (!(await node.isVisible().catch(() => false))) return;
const switcher = node.locator('.ant-tree-switcher').first();
const closed = await switcher
.evaluate((el) => el.classList.contains('ant-tree-switcher_close'))
.catch(() => false);
if (closed) await this.click(switcher, `展开 ${title}`);
await this.waitSettle(200);
}
async collapseTreeNode(title: string): Promise<void> {
const node = this.treeNodeRow(title);
if (!(await node.isVisible().catch(() => false))) return;
const switcher = node.locator('.ant-tree-switcher').first();
const open = await switcher
.evaluate((el) => el.classList.contains('ant-tree-switcher_open'))
.catch(() => false);
if (open) await this.click(switcher, `收起 ${title}`);
await this.waitSettle(200);
}
async isTreeChecked(title: string): Promise<boolean> {
const box = this.locators.treeCheckbox(title);
const cls = (await box.getAttribute('class').catch(() => '')) ?? '';
return cls.includes('ant-tree-checkbox-checked');
}
async isTreeDisabled(title: string): Promise<boolean> {
const box = this.locators.treeCheckbox(title);
const cls = (await box.getAttribute('class').catch(() => '')) ?? '';
return cls.includes('ant-tree-checkbox-disabled');
}
async checkModule(moduleName = '厂商管理'): Promise<void> {
const box = this.locators.treeCheckbox(moduleName);
if (!(await box.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`权限树无「${moduleName}」节点`);
return;
}
if (await this.isTreeDisabled(moduleName)) {
await this.skipWhenDataInsufficient(`「${moduleName}」勾选被锁定`);
return;
}
await this.click(box, `勾选 ${moduleName}`);
await this.waitSettle(300);
}
async refreshTree(): Promise<void> {
await this.click(this.locators.refreshBtn, '刷 新');
await this.waitSpinGone();
await this.waitSettle(300);
}
async deleteEntry(roleName: string): Promise<Locator> {
const row = this.leftRoleItems().filter({ hasText: roleName }).first();
return row
.getByRole('button', { name: /删/ })
.or(row.locator('.anticon-delete, [aria-label*="delete"], [aria-label*="删"]'))
.first();
}
editEntry(roleName: string): Locator {
const row = this.leftRoleItems().filter({ hasText: roleName }).first();
return row
.getByRole('button', { name: /编\s*辑/ })
.or(row.locator('.anticon-edit, .anticon-form, [aria-label*="edit"], [aria-label*="编辑"]'))
.first();
}
normalizeRoleLabel(text: string): string {
return text.replace(/\s/g, '').replace(/[((].*$/, '').trim();
}
roleNamesMatch(selected: string, inForm: string): boolean {
const a = this.normalizeRoleLabel(selected);
const b = this.normalizeRoleLabel(inForm);
if (!a || !b) return false;
return a === b || a.includes(b) || b.includes(a);
}
async readEditRoleNameFromDialog(): Promise<string> {
const dialog = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
const labeled = dialog
.locator('.ant-form-item')
.filter({ hasText: /角色名称|角\s*色名/ })
.locator('input')
.first();
if (await labeled.isVisible().catch(() => false)) {
return ((await labeled.inputValue()) ?? '').trim();
}
const placeholder = dialog.getByPlaceholder(/角色/).first();
if (await placeholder.isVisible().catch(() => false)) {
return ((await placeholder.inputValue()) ?? '').trim();
}
return ((await dialog.locator('input:not([type="hidden"])').first().inputValue().catch(() => '')) ?? '').trim();
}
async openEditRoleDialog(roleName?: string): Promise<{ selectedRoleName: string; dialog: Locator }> {
const selectedRoleName = roleName ?? (await this.sampleRoleName());
await this.selectRole(selectedRoleName);
const cornerEdit = this.locators.editRoleIcon;
const rowEdit = this.editEntry(selectedRoleName);
if (await cornerEdit.isVisible().catch(() => false)) {
await this.click(cornerEdit, '左栏右下角编辑');
} else if (await rowEdit.isVisible().catch(() => false)) {
await this.click(rowEdit, '行内编辑角色');
} else {
await this.skipWhenDataInsufficient('编辑入口不可见');
return { selectedRoleName, dialog: this.dialog() };
}
await this.waitSettle(400);
const dialog = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
await expect(dialog, '编辑角色弹窗').toBeVisible({ timeout: 12_000 });
return { selectedRoleName, dialog };
}
editRoleDialog(): Locator {
return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
}
async saveEditRoleDialog(): Promise<void> {
const dialog = this.editRoleDialog();
const save = dialog.getByRole('button', { name: /保\s*存/ }).first();
await expect(save, '弹框保存按钮').toBeVisible();
await this.assertBeforeBusinessAction('保存编辑角色');
await this.click(save, '保存编辑角色', { operation: 'write' });
await this.waitSpinGone();
await this.waitSettle(400);
}
async expectToastSuccess(text: string | RegExp): Promise<void> {
const toast = this.page
.locator('.ant-message-notice, .ant-message-success, .ant-notification-notice, .ant-message')
.filter({ hasText: text })
.first();
await expect(toast, `应提示成功:${String(text)}`).toBeVisible({ timeout: 12_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertPageHealthy('保存后页面健康');
}
async openDeleteConfirm(): Promise<{ roleName: string; confirm: Locator } | null> {
const names = await this.leftRoleNames();
const target = names.find((n) => n && !/管理员/.test(n)) ?? names[0];
if (!target) {
await this.skipWhenDataInsufficient('无可删除角色');
return null;
}
await this.selectRole(target);
const del = await this.deleteEntry(target);
if (!(await del.isVisible().catch(() => false))) {
const globalDel = this.page.locator('.anticon-delete, [aria-label*="delete"]').first();
if (!(await globalDel.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无删除入口');
return null;
}
await this.click(globalDel, '删除');
} else {
await this.click(del, '删除');
}
const confirm = this.page
.locator('.ant-modal-confirm, .ant-popconfirm, .ant-modal')
.filter({ hasText: /提示|删除|确认/ })
.last();
await expect(confirm, '删除确认框').toBeVisible({ timeout: 8_000 });
return { roleName: target, confirm };
}
addRoleDialog(): Locator {
return this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open')).last();
}
async openAddRoleDialog(): Promise<Locator> {
const add = this.locators.addRoleBtn;
if (!(await add.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('「角色」按钮不可见');
return this.addRoleDialog();
}
await this.assertBeforeBusinessAction('打开新增角色');
await this.click(add, '角 色');
await this.waitSettle(400);
const dialog = this.addRoleDialog();
await expect(dialog, '新增角色弹窗').toBeVisible({ timeout: 12_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertUiReady('打开新增角色弹窗');
return dialog;
}
async openAddRoleForm(): Promise<{ before: number; opened: boolean }> {
const before = (await this.leftRoleNames()).length;
const dialog = await this.openAddRoleDialog();
const opened = await dialog.isVisible().catch(() => false);
return { before, opened };
}
addRoleDialogCloseIcon(): Locator {
const dialog = this.addRoleDialog();
return dialog
.locator('.ant-modal-close')
.first()
.or(dialog.getByRole('button', { name: /close|关闭/i }).first())
.or(dialog.getByRole('img', { name: 'close' }).first());
}
async closeAddRoleDialogByX(): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框应可见').toBeVisible();
const close = this.addRoleDialogCloseIcon();
await expect(close, '右上角 X 关闭').toBeVisible();
await this.click(close, '点击新增弹框 X 关闭');
await expect(dialog, 'X 关闭后弹框应隐藏').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertUiReady('X 关闭新增角色弹框后');
}
async cancelAddRoleDialog(): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框应可见').toBeVisible();
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭/ }).first();
await expect(cancel, '取消按钮').toBeVisible();
await this.click(cancel, '取消新增角色');
await expect(dialog, '取消后弹框应关闭').toBeHidden({ timeout: 8_000 });
expect(await this.page.locator('.ant-message-error').count(), '不应出现错误提示').toBe(0);
await this.assertUiReady('取消新增角色后');
}
async cancelAddRole(): Promise<void> {
if (await this.addRoleDialog().isVisible().catch(() => false)) {
await this.cancelAddRoleDialog();
return;
}
const layer = this.dialog().or(this.page.locator('.ant-modal:visible'));
if (await layer.first().isVisible().catch(() => false)) {
await this.closeDialog();
return;
}
await this.page.keyboard.press('Escape');
}
addRoleNameFormItem(): Locator {
const dialog = this.addRoleDialog();
return this.locators
.formItem('角色名称', dialog)
.or(dialog.locator('.ant-form-item').filter({ hasText: /角色名称|角\s*色名/ }).first());
}
queryConfigCheckbox(label = '查询配置化'): Locator {
const dialog = this.addRoleDialog();
return dialog.locator('.ant-checkbox-wrapper').filter({ hasText: label }).first();
}
async ensureQueryConfigCheckbox(label = '查询配置化'): Promise<Locator> {
const wrapper = this.queryConfigCheckbox(label);
if (!(await wrapper.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`新增弹框无「${label}」复选框`);
}
await expect(wrapper, `「${label}」复选框`).toBeVisible();
return wrapper;
}
async isQueryConfigChecked(label = '查询配置化'): Promise<boolean> {
const wrapper = await this.ensureQueryConfigCheckbox(label);
return wrapper.locator('input[type="checkbox"]').isChecked();
}
async setQueryConfigChecked(checked: boolean, label = '查询配置化'): Promise<void> {
const wrapper = await this.ensureQueryConfigCheckbox(label);
const input = wrapper.locator('input[type="checkbox"]');
const current = await input.isChecked();
if (current !== checked) {
await this.click(wrapper, checked ? `勾选${label}` : `取消勾选${label}`);
await this.waitSettle(200);
}
expect(await input.isChecked(), `${label} 勾选态应为 ${checked}`).toBe(checked);
}
async saveAddRoleDialog(): Promise<void> {
const dialog = this.addRoleDialog();
const save = dialog.getByRole('button', { name: /保\s*存/ }).first();
await expect(save, '弹框保存按钮').toBeVisible();
await this.click(save, '保存新增角色');
await this.waitSettle(400);
}
async expectAddRoleNameRequired(message = '请输入角色名称!'): Promise<void> {
const dialog = this.addRoleDialog();
await expect(dialog, '新增弹框仍应打开').toBeVisible();
const item = this.addRoleNameFormItem();
await expect(item, '角色名称表单项应校验失败').toHaveClass(/ant-form-item-has-error/);
const explain = item.locator('.ant-form-item-explain-error').filter({ hasText: message });
await expect(explain.first(), `应提示:${message}`).toBeVisible({ timeout: 8_000 });
const inputWrap = item.locator('input, .ant-input-affix-wrapper').first();
const wrapClass = (await inputWrap.getAttribute('class').catch(() => '')) ?? '';
const fieldLooksInvalid =
/ant-input-status-error|ant-input-affix-wrapper-status-error/.test(wrapClass) ||
(await item.getAttribute('class').catch(() => ''))?.includes('ant-form-item-has-error');
expect(fieldLooksInvalid, '角色名称输入框应变红(校验失败态)').toBeTruthy();
expect(await this.page.locator('.ant-message-error').count(), '不应出现系统级错误 toast').toBe(0);
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator';
/** 医院资料页共用:无 iframe、菜单进入、表格/分页/弹窗。 */
export class BasicHospitalSharedPage extends BasePage {
protected readonly shared: BasicSharedLocators;
constructor(page: Page) {
super(page);
this.shared = new BasicSharedLocators(page);
}
async openPath(path: string): Promise<void> {
await this.goto(path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertUiReady(`打开 ${path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
async openViaMenu(menuName: string, pathPart: string): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.shared.menuBasic, '展开基础设置');
const hospital = this.shared.menuHospital;
if (await hospital.isVisible().catch(() => false)) {
await this.click(hospital, '展开医院资料');
}
await this.click(this.shared.menuItem(menuName), `进入${menuName}`);
await this.page.waitForURL((url) => url.pathname.includes(pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertUiReady(`菜单进入${menuName}`);
}
async expectUrlContains(part: string): Promise<void> {
expect(this.page.url(), `URL 应含 ${part}`).toContain(part);
}
async expectNotOnSiblingPages(forbidden: string[]): Promise<void> {
const url = this.page.url();
for (const part of forbidden) {
expect(url, `不应进入 ${part}`).not.toContain(part);
}
}
dataRows(): Locator {
return this.shared.dataRows;
}
async getDataRowCount(): Promise<number> {
return this.dataRows().count();
}
async getTotalCount(): Promise<number> {
const text = (await this.shared.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
async expectListOrEmpty(): Promise<void> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
if (rows > 0) {
expect(await this.getTotalCount(), '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1);
return;
}
const emptyVisible = await this.shared.empty.isVisible().catch(() => false);
const emptyText = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(emptyVisible || emptyText || (await this.getTotalCount()) === 0, '无数据时空态应明确').toBeTruthy();
}
async expectHeaders(names: string[]): Promise<void> {
const text = ((await this.shared.tableHeader.allTextContents()) ?? []).join(' ');
for (const name of names) {
expect(text, `表头应含「${name}」`).toContain(name);
}
}
async headerIndex(name: string): Promise<number> {
const headers = await this.shared.tableHeader.allTextContents();
const index = headers.findIndex((item) => item.replace(/\s/g, '').includes(name.replace(/\s/g, '')));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async firstRow(): Promise<Locator> {
const row = this.dataRows().first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
async expectFormError(): Promise<void> {
const error = this.shared.formError.first();
const toast = this.page.locator('.ant-message-error, .ant-notification-notice').first();
await expect(error.or(toast), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 });
}
async closeDialogIfOpen(): Promise<void> {
const dialog = this.shared.dialog;
if (!(await dialog.isVisible().catch(() => false))) return;
const cancel = dialog.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹窗');
} else {
await this.page.keyboard.press('Escape');
}
await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
}
async selectDropdown(select: Locator, optionText: string): Promise<void> {
await this.click(select, '打开下拉');
const option = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option').filter({ hasText: optionText }).first();
await this.click(option, `选择 ${optionText}`);
await this.waitSettle(200);
}
async clickPaginationPage(pageNum: number): Promise<void> {
const item = this.page.locator(`.ant-pagination-item-${pageNum}`).first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, `翻到第 ${pageNum} 页`);
return;
}
await this.click(this.shared.nextPage, '下一页');
}
async hasPaginationItem(pageNum: number): Promise<boolean> {
return this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false);
}
formItem(label: string): Locator {
return this.page.locator('.ant-form-item').filter({ hasText: label }).first();
}
dialogFormItem(label: string): Locator {
return this.shared.dialog.locator('.ant-form-item').filter({ hasText: label }).first();
}
async pickCascaderFirstPath(): Promise<void> {
const selector = this.shared.dialog.locator('.ant-cascader, .ant-select').filter({ has: this.page.getByText('请选择') }).first();
const fallback = this.dialogFormItem('所在地区').locator('.ant-select, .ant-cascader').first();
const target = (await selector.count()) ? selector : fallback;
await this.click(target, '打开所在地区');
for (let i = 0; i < 3; i += 1) {
const item = this.page.locator('.ant-cascader-menu:visible .ant-cascader-menu-item:not(.ant-cascader-menu-item-disabled)').first();
if (!(await item.isVisible().catch(() => false))) break;
await this.click(item, `选择地区第 ${i + 1} 级`);
await this.waitSettle(150);
}
}
uniqStamp(prefix: string): string {
return `${prefix}-${Date.now()}`;
}
get hospitalData() {
return basicHospitalData;
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { BasicUserSharedLocators } from '../../locators/spd/basic-user-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { skipIfInsufficientForPageSizeChange } from './helpers/pagination-pagesize.helper';
export type UserMeta = {
path: string;
pathPart: string;
menu: string;
forbidden?: string[];
};
export type UserListSnapshot = {
rows: number;
total: number;
emptyVisible: boolean;
};
/** 用户管理三页共用:无 iframe、侧栏叶子菜单、表格/弹窗。 */
export class BasicUserSharedPage extends BasePage {
readonly locators: BasicUserSharedLocators;
constructor(
page: Page,
protected readonly meta: UserMeta,
) {
super(page);
this.locators = new BasicUserSharedLocators(page);
}
get pagePath(): string {
return this.meta.path;
}
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.assertUiReady(`打开 ${this.meta.path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
async openFromMenu(): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.locators.menuBasic, '展开基础设置');
const title = this.locators.userSubmenuTitle.first();
const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first();
const opened = await submenu
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, '展开用户管理');
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.page.waitForURL((url) => {
const pathname = url.pathname;
if (!pathname.includes(this.meta.pathPart)) return false;
return !(this.meta.forbidden ?? []).some((token) => pathname.includes(token));
}, { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertUiReady(`菜单进入${this.meta.menu}`);
}
async waitContentReady(): Promise<void> {
const markers = this.page
.locator('.ant-table-thead th, .ant-empty, .menu-role-tree, .ant-tree, [placeholder="姓名、登录账号"]')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady({ health: 'ui' });
}
async waitForTableReady(options?: { health?: 'ui' | 'business' }): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertAfterTableWait('列表加载', options?.health ?? 'business');
}
button(name: string, root?: Locator): Locator {
return this.locators.button(name, root ?? this.page);
}
async clickButton(name: string, root?: Locator): Promise<void> {
await this.click(this.button(name, root).first(), name);
}
async query(): Promise<void> {
await this.assertBeforeBusinessAction('查询');
await this.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.assertBeforeBusinessAction('重置');
await this.clickButton('重置');
await this.waitForTableReady();
}
dialog(): Locator {
return this.locators.dialog;
}
async headerCells(): Promise<Locator> {
await this.page
.locator('.ant-table-thead th')
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
return this.page.locator('.ant-table-thead').first().locator('th');
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const needle = name.replace(/\s/g, '');
const exact = texts.findIndex((item) => item === needle);
if (exact >= 0) return exact;
const index = texts.findIndex((item) => item.includes(needle));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async listRows(): Promise<Locator> {
return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
async getDataRowCount(): Promise<number> {
return (await this.listRows()).count();
}
async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async tryCell(row: Locator, header: string): Promise<string> {
try {
return (await this.cellText(row, header)).trim();
} catch {
return '';
}
}
async firstRow(): Promise<Locator> {
const row = (await this.listRows()).first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
async snapshotList(): Promise<UserListSnapshot> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const emptyVisible =
(await this.locators.empty.isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 12);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
async fillName(keyword: string): Promise<void> {
await expect(this.locators.nameInput, '姓名/登录账号').toBeVisible();
await this.fill(this.locators.nameInput, keyword, '填写姓名或登录账号');
}
async selectFormOption(label: string, optionText: string): Promise<void> {
const select = this.locators.formSelect(label);
await expect(select, `筛选「${label}」`).toBeVisible();
await this.click(select, `打开${label}`);
const compact = optionText.replace(/\s/g, '').slice(0, 12);
const escaped = compact.slice(0, 8).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const option = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option')
.filter({ hasText: new RegExp(escaped) })
.first();
await this.click(option, `选择 ${optionText}`);
await this.waitSettle(200);
}
async expandAdvanced(): Promise<void> {
await this.click(this.locators.advancedFilter, '高级筛选');
await this.waitSettle(400);
}
rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async activePageNumber(): Promise<string> {
return ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
}
async goToPageNumber(pageNum: number): Promise<string> {
if (pageNum <= 1) {
const first = this.page.locator('.ant-pagination-item-1').first();
if (await first.isVisible().catch(() => false)) {
await this.click(first, '回到第 1 页');
} else {
await this.click(this.page.locator('.ant-pagination-prev').first(), '上一页');
}
} else {
const item = this.page.locator(`.ant-pagination-item-${pageNum}`).first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, `翻到第 ${pageNum} 页`);
} else {
await this.click(this.locators.nextPage, '下一页');
}
}
await this.waitForTableReady();
return this.activePageNumber();
}
async goToPage2(): Promise<{ firstLogin: string; secondLogin: string; active: string }> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const firstLogin = await this.tryCell(await this.firstRow(), '登录账号');
const active = await this.goToPageNumber(2);
const secondLogin = await this.tryCell(await this.firstRow(), '登录账号');
return { firstLogin, secondLogin, active };
}
async pageSizeChangerText(): Promise<string> {
return ((await this.locators.pageSizeChanger.innerText().catch(() => '')) ?? '').replace(/\s+/g, ' ');
}
async changePageSizeTo(toLabel: string): Promise<void> {
const changer = this.locators.pageSizeChanger;
if (!(await changer.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('当前页面无每页条数切换');
}
const sizeNum = (toLabel.match(/\d+/) ?? ['20'])[0];
const current = await this.pageSizeChangerText();
if (current.includes(sizeNum) && current.includes('条')) {
return;
}
await this.click(changer, '打开每页条数');
const option = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: new RegExp(`${sizeNum}\\s*条`) })
.first();
await this.click(option, `选择 ${toLabel}`);
await this.waitForTableReady();
const after = await this.pageSizeChangerText();
if (!after.includes(sizeNum)) {
throw new Error(`切换每页条数未生效:期望含 ${sizeNum},当前「${after || '(空)'}」`);
}
}
/** 切换为 20 条/页,返回切换前后可见行数与总条数(总条数应不变)。 */
async switchToTwentyPerPage(): Promise<{ total: number; rowsBefore: number; rowsAfter: number }> {
await skipIfInsufficientForPageSizeChange({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
});
const total = await this.getTotalCount();
if (total <= 10) {
await this.skipWhenDataInsufficient('总条数不大于 10,无法验证切换 20 条/页后列表变化');
}
const rowsBefore = await this.getDataRowCount();
const expectedAfter = Math.min(total, 20);
if (rowsBefore === expectedAfter) {
await this.skipWhenDataInsufficient(
'当前页可见行数已等于切换 20 条/页后的期望,无法验证列表同步变化',
);
}
await this.changePageSizeTo('20 条/页');
const rowsAfter = await this.getDataRowCount();
return { total, rowsBefore, rowsAfter };
}
async openAddDialog(): Promise<Locator> {
await this.clickButton('新增');
const dialog = this.dialog();
await expect(dialog, '新增弹窗').toBeVisible({ timeout: 12_000 });
return dialog;
}
async openRowAction(name: string): Promise<Locator> {
const row = await this.firstRow();
await this.click(this.rowAction(row, name), name);
await this.waitSettle(400);
return row;
}
async closeDialog(): Promise<void> {
const dialog = this.dialog();
if (!(await dialog.isVisible().catch(() => false))) return;
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹窗');
} else {
await this.page.keyboard.press('Escape');
}
await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
}
async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|已存在|重复|占用/).first();
await expect(this.locators.formError.first().or(hint), '应出现拦截提示').toBeVisible({ timeout: 8_000 });
const stayed =
(await this.page.locator('.ant-modal:visible').count()) > 0 ||
(await this.page.locator('.ant-drawer-open').count()) > 0;
expect(stayed, '拦截后弹窗不应关闭').toBeTruthy();
}
async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> {
await this.page.goto(this.meta.path, { waitUntil: 'domcontentloaded' });
await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined);
const onLogin = /\/login/i.test(this.page.url());
const hasBusinessRow = await this.page
.locator('.ant-table-tbody tr.ant-table-row, .menu-role-tree')
.first()
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
async fillDialogField(locator: Locator, value: string, label: string): Promise<void> {
if (!(await locator.isVisible().catch(() => false))) return;
await this.fill(locator, value, label);
}
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment