Commit 6a181bea authored by liguangyu06's avatar liguangyu06
Browse files

将基础管理等未入库改动暂存到 temp 分支,便于远程备份。

新模块用例尚未具备通关凭证,本次按临时备份跳过本地门禁。
parent f6eec849
import { expect, type Locator, type Page } from '@playwright/test';
import { surgeryData } from '../../data/surgery.data';
import { SurgerySharedPage, type SurgeryMeta } from './surgery-shared.page';
const META: SurgeryMeta = {
path: surgeryData.stdPkgPath,
pathPart: surgeryData.stdPkgPathPart,
menu: surgeryData.menuStdPkg,
siblings: [
{ name: surgeryData.menuBasePkg, pathPart: surgeryData.basePkgPathPart },
{ name: surgeryData.menuOperType, pathPart: surgeryData.operTypePathPart },
],
forbidden: [surgeryData.basePkgPathPart, surgeryData.operTypePathPart],
};
/** 标准套包维护(CSV SURG-STD-001..029)。 */
export class SurgeryStdPkgPage extends SurgerySharedPage {
constructor(page: Page) {
super(page, META);
}
// ---- 定位 ----
categorySearch() {
return this.page.getByPlaceholder('请输入术种分类名称、术种名称、标准套包名称').first();
}
addBasePkgBtn() {
return this.locators.button('添加基础包').first();
}
addLoosePartBtn() {
return this.locators.button('添加散件').first();
}
productNameInput() {
return this.filterById('#table_search_form_keyword');
}
specModelInput() {
return this.filterById('#table_search_form_goodsSpec');
}
mfrsNameInput() {
return this.filterById('#table_search_form_goodsMfrsName');
}
// ---- 左树分类 ----
async searchCategory(keyword: string): Promise<void> {
await this.fill(this.categorySearch(), keyword, `填写术种分类名称「${keyword}」`);
await this.waitSettle(600);
}
// ---- 筛选 ----
async searchByProductName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_keyword', '产品名称');
}
async searchBySpecModel(): Promise<string> {
return this.searchByFirstRow('#table_search_form_goodsSpec', '规格型号');
}
async searchByMfrsName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_goodsMfrsName', '器械注册人');
}
/** CSV SURG-STD-015:mock 反例空态。 */
async searchMockEmpty(): Promise<void> {
await this.fillFilterById('#table_search_form_keyword', surgeryData.mockStdKeyword);
await this.query();
}
/** CSV SURG-STD-013:过滤后重置,返回筛选框是否清空。 */
async filterThenReset(): Promise<{ before: number; afterValue: string }> {
const keyword = await this.sampleCell('产品名称');
await this.fillFilterById('#table_search_form_keyword', keyword);
await this.query();
const before = await this.getTotalCount();
await this.reset();
const afterValue = await this.productNameInput().inputValue().catch(() => '');
return { before, afterValue };
}
// ---- 包名称分组展开/收起 ----
/** 首个可展开的包分组(含 expand 图标的行)。 */
firstExpandableGroup(): Locator {
return this.page
.locator('.ant-table-tbody tr.ant-table-row')
.filter({ has: this.page.locator('.ant-table-row-expand-icon') })
.first();
}
/**
* CSV SURG-STD-012:读取首个包分组名,展开后取子项行数,再收起。
* 返回展开/收起时「子项行」定位数与可见性,供用例断言「展开显示、收起隐藏」。
*/
async toggleFirstGroup(): Promise<{
groupName: string;
expandedChildCount: number;
collapsedChildCount: number;
expandedChildVisible: boolean;
collapsedChildVisible: boolean;
}> {
const group = this.firstExpandableGroup();
if (!(await group.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无可用包分组可展开');
}
const groupName = ((await group.innerText().catch(() => '')) ?? '').replace(/\s/g, '').slice(0, 20);
const expandIcon = group.locator('.ant-table-row-expand-icon').first();
await this.click(expandIcon, '展开包分组');
await this.waitSettle(500);
const expandedChild = this.childRows();
const expandedChildCount = await expandedChild.count();
const expandedChildVisible = await expandedChild.first().isVisible().catch(() => false);
await this.click(expandIcon, '收起包分组');
await this.waitSettle(500);
const collapsedChild = this.childRows();
const collapsedChildCount = await collapsedChild.count();
const collapsedChildVisible = await collapsedChild.first().isVisible().catch(() => false);
return { groupName, expandedChildCount, collapsedChildCount, expandedChildVisible, collapsedChildVisible };
}
/** 子项行:分组展开后 TR 带 .ant-table-row-level-1(或无 expand-icon 的明细行)。 */
childRows(): Locator {
return this.page.locator(
'.ant-table-tbody tr.ant-table-row-level-1, .ant-table-tbody tr.ant-table-row:not(:has(.ant-table-row-expand-icon))',
);
}
/** 展开首个分组并返回子项行定位(供行内操作)。 */
async expandFirstGroup(): Promise<{ groupName: string; childRows: Locator }> {
const group = this.firstExpandableGroup();
if (!(await group.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无可用包分组可展开');
}
const groupName = ((await group.innerText().catch(() => '')) ?? '').replace(/\s/g, '').slice(0, 20);
await this.click(group.locator('.ant-table-row-expand-icon').first(), '展开包分组');
await this.waitSettle(500);
const childRows = this.page.locator('.ant-table-tbody tr.ant-table-row');
return { groupName, childRows };
}
// ---- 添加基础包弹窗 ----
/** CSV SURG-STD-016:打开添加基础包弹窗。 */
async openAddBasePkgDialog(): Promise<void> {
await this.click(this.addBasePkgBtn(), '添加基础包');
await this.expectDialogVisible();
await this.waitSpinGone();
}
addBasePkgSearchInput(): Locator {
return this.page.getByPlaceholder('请输入分类/基础包名称').first();
}
/** 「确定(N)」按钮:返回定位与当前计数。 */
confirmCountButton(): Locator {
return this.dialog().getByRole('button').filter({ hasText: /确定\s*[((]?\s*\d*\s*[))]?/ }).first();
}
async confirmCount(): Promise<number | null> {
const text = ((await this.confirmCountButton().innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const match = text.match(/[((](\d+)[))]/);
return match ? Number(match[1]) : null;
}
/** 弹窗内基础包勾选框(首列 checkbox)。 */
dialogCheckboxes(): Locator {
return this.dialog().locator('.ant-table-tbody input[type="checkbox"], .ant-checkbox-input');
}
/**
* CSV SURG-STD-017:在弹窗搜索框输入真实基础包关键字并回车,勾选 1 个基础包。
* 返回关键字与「确定(N)」计数前后值,供用例断言筛选生效与计数联动。
*/
async filterAndCheckFirst(keyword?: string): Promise<{
keyword: string;
before: number | null;
after: number | null;
candidateRows: number;
}> {
await this.openAddBasePkgDialog();
const before = await this.confirmCount();
const search = this.addBasePkgSearchInput();
// 关键字:优先入参;否则从弹窗明细首行取基础包名称(真实关键字,不再填空串)
const kw = keyword ?? (await this.sampleDialogBasePkgName());
let candidateRows = 0;
if (await search.isVisible().catch(() => false)) {
await this.fill(search, kw, `搜索基础包「${kw}」`);
await search.press('Enter').catch(() => undefined);
await this.waitSettle(600);
candidateRows = await this.dialogCheckboxes().count();
}
const checkbox = this.dialogCheckboxes().first();
await this.click(checkbox, '勾选首个基础包');
await this.waitSettle(300);
const after = await this.confirmCount();
return { keyword: kw, before, after, candidateRows };
}
/** 弹窗明细首行基础包名称(作为真实筛选关键字)。 */
async sampleDialogBasePkgName(): Promise<string> {
const rows = this.dialog().locator('.ant-table-tbody tr.ant-table-row');
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const hit = text.match(/[\u4e00-\u9fa5A-Za-z0-9-]{4,}/);
if (hit) return hit[0].slice(0, 8);
}
await this.skipWhenDataInsufficient('弹窗基础包明细为空');
return '';
}
/** CSV SURG-STD-018:构成数量步进器点「Increase Value」,返回数量前后数值。 */
async increaseFirstQuantity(): Promise<{ before: number; after: number }> {
const increase = this.dialog().getByLabel('Increase Value').first();
const visible = await increase.isVisible().catch(() => false);
if (!visible) {
await this.skipWhenDataInsufficient('无「构成数量」步进器(未勾选基础包)');
}
const spin = this.dialog().getByRole('spinbutton').first();
const before = Number((await spin.inputValue().catch(() => '1')) || '1');
await this.click(increase, 'Increase Value');
await this.waitSettle(300);
const after = Number((await spin.inputValue().catch(() => '1')) || '1');
return { before, after };
}
/** CSV SURG-STD-019:勾选后取消,返回 { total, cancelOk }。 */
async addBasePkgThenCancel(): Promise<{ total: number; cancelOk: boolean }> {
const total = await this.getTotalCount();
await this.openAddBasePkgDialog();
const checkbox = this.dialogCheckboxes().first();
if (await checkbox.isVisible().catch(() => false)) {
await this.click(checkbox, '勾选首个基础包');
}
await this.closeDialogIfOpen();
const cancelOk = !(await this.dialog().isVisible().catch(() => true));
return { total, cancelOk };
}
/** CSV SURG-STD-020:未选择时确定按钮是否禁用。 */
async confirmDisabledWhenEmpty(): Promise<boolean> {
await this.openAddBasePkgDialog();
const btn = this.confirmCountButton();
const disabled = await btn.isDisabled().catch(() => false);
const cls = (await btn.getAttribute('class').catch(() => '')) ?? '';
return disabled || /disabled/.test(cls);
}
/**
* CSV SURG-STD-021:再次添加同一基础包。
* 返回首次添加的产品名与「同一产品名出现的行数」,供用例断言不产生重复脏行。
*/
async addSameBasePkgAgain(): Promise<{
before: number;
after: number;
productName: string;
sameProductRows: number;
}> {
// 先记录首次添加的目标产品名(弹窗明细首行)
const total0 = await this.getTotalCount();
await this.openAddBasePkgDialog();
const productName = await this.sampleDialogBasePkgName();
const checkbox = this.dialogCheckboxes().first();
if (await checkbox.isVisible().catch(() => false)) {
await this.click(checkbox, '勾选首个基础包');
}
await this.clickConfirm('确 定');
await expect(this.dialog()).toBeHidden({ timeout: 15_000 }).catch(() => undefined);
await this.waitForTableReady();
const after = await this.getTotalCount();
const sameProductRows = await this.countRowsContaining(productName);
return { before: total0, after, productName, sameProductRows };
}
/** 统计列表中包含指定文本的行数(用于重复添加防重判定)。 */
async countRowsContaining(keyword: string): Promise<number> {
const needle = keyword.replace(/\s/g, '');
const rows = this.dataRows();
const count = await rows.count();
let hits = 0;
for (let i = 0; i < count; i += 1) {
const text = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
if (text.includes(needle)) hits += 1;
}
return hits;
}
// ---- 添加散件弹窗 ----
/** CSV SURG-STD-022:打开添加散件弹窗(产品列表视图)。 */
async openAddLoosePartDialog(): Promise<void> {
await this.click(this.addLoosePartBtn(), '添加散件');
await this.expectDialogVisible();
await this.waitSpinGone();
await this.waitSettle(500);
}
/** 弹窗内视图切换(产品列表 / 规格明细 / 应拣明细)。 */
dialogViewTab(name: string): Locator {
return this.dialog()
.locator('.ant-tabs-tab, .ant-radio-button-wrapper, .ant-segmented-item')
.filter({ hasText: new RegExp(name) })
.first()
.or(this.dialog().getByText(new RegExp(name)).first());
}
/** 散件弹窗内的产品名称筛选框(限定在弹窗内,避免命中主列表)。 */
loosePartProductSearch(): Locator {
return this.dialog().getByPlaceholder(/产品名称/).first();
}
/**
* CSV SURG-STD-023:在产品名称筛选框输入样例关键字,勾选首个匹配散件。
* 返回关键字、筛选后候选行数、勾选后应拣计数,供用例断言「筛选生效 + 进入应拣明细 + 计数同步」。
*/
async filterAndCheckLoosePart(): Promise<{
keyword: string;
candidateRows: number;
selectedCount: number;
}> {
const keyword = await this.sampleCell('产品名称');
const search = this.loosePartProductSearch();
if (await search.isVisible().catch(() => false)) {
await this.fill(search, keyword, `填写产品名称筛选「${keyword}」`);
await search.press('Enter').catch(() => undefined);
await this.waitSettle(600);
}
const candidateRows = await this.dialog().locator('.ant-table-tbody tr.ant-table-row').count();
const checkbox = this.dialogCheckboxes().first();
await this.click(checkbox, '勾选首个散件');
await this.waitSettle(400);
const selectedCount = await this.loosePartSelectedCount();
return { keyword, candidateRows, selectedCount };
}
/** 应拣明细已选条数(弹窗内明细行数或选中计数徽标)。 */
async loosePartSelectedCount(): Promise<number> {
const badgeText = ((await this.dialog()
.locator('.ant-badge, [class*="count"], [class*="total"]')
.first()
.innerText()
.catch(() => '')) ?? '');
const badge = badgeText.match(/(\d+)/);
if (badge) return Number(badge[1]);
return this.dialog().locator('.ant-table-tbody tr.ant-table-row.ant-table-row-selected').count();
}
/**
* CSV SURG-STD-024:切换到规格/应拣明细视图。
* 返回明细视图是否可见、明细表头文本,供用例断言字段完整。
*/
async switchLoosePartDetailView(): Promise<{ visible: boolean; headers: string[] }> {
const tab = this.dialogViewTab('应拣明细').or(this.dialogViewTab('规格明细'));
if (await tab.isVisible().catch(() => false)) {
await this.click(tab, '切换应拣明细视图');
await this.waitSettle(400);
}
const headers = (await this.dialog().locator('.ant-table-thead th').allTextContents()).map((t) => t.replace(/\s/g, ''));
const visible = headers.length > 0;
return { visible, headers };
}
/** CSV SURG-STD-025:读取耗材品种数 / 耗材总数(结构化数值)。 */
async readLoosePartStats(): Promise<{ kind: number | null; total: number | null }> {
const kindText = await this.dialog().getByText(/耗材品种数/).first().innerText().catch(() => '');
const totalText = await this.dialog().getByText(/耗材总数/).first().innerText().catch(() => '');
const pick = (text: string) => {
const match = text.match(/(\d+)/);
return match ? Number(match[1]) : null;
};
return { kind: pick(kindText), total: pick(totalText) };
}
/** CSV SURG-STD-026:勾选散件后取消,返回 { total, cancelOk }。 */
async addLoosePartThenCancel(): Promise<{ total: number; cancelOk: boolean }> {
const total = await this.getTotalCount();
await this.openAddLoosePartDialog();
await this.filterAndCheckLoosePart();
await this.closeDialogIfOpen();
const cancelOk = !(await this.dialog().isVisible().catch(() => true));
return { total, cancelOk };
}
/** CSV SURG-STD-027:万级首屏可交互性(筛选/分页可用,不卡死)。 */
async loosePartFirstScreen(): Promise<{ total: number | null; firstRowVisible: boolean; pagerVisible: boolean }> {
await this.openAddLoosePartDialog();
const pagerText = ((await this.dialog().locator('.ant-pagination-total-text').first().innerText().catch(() => '')) ?? '');
const match = pagerText.match(/共\s*(\d+)\s*条/);
const firstRowVisible = await this.dialog()
.locator('.ant-table-tbody tr.ant-table-row')
.first()
.isVisible()
.catch(() => false);
const pagerVisible = await this.dialog().locator('.ant-pagination').first().isVisible().catch(() => false);
return { total: match ? Number(match[1]) : null, firstRowVisible, pagerVisible };
}
// ---- 行内操作(编辑 / 移除)----
/** CSV SURG-STD-028:展开后子项行编辑并回填。 */
async editFirstChildRow(): Promise<{ groupName: string }> {
const { groupName } = await this.expandFirstGroup();
const row = await this.rowWithAction('编 辑');
if (!row) {
await this.skipWhenDataInsufficient('展开后无「编辑」子项行');
}
await this.click(this.rowAction(row as Locator, '编 辑'), '编 辑');
await this.expectDialogVisible();
await this.waitSpinGone();
return { groupName };
}
/** CSV SURG-STD-029:展开后子项行移除并取消,返回 { before, cancelShown }。 */
async removeFirstChildRowThenCancel(): Promise<{ before: number; cancelShown: boolean }> {
await this.expandFirstGroup();
const before = await this.getDataRowCount();
const row = await this.rowWithAction('移 除');
if (!row) {
await this.skipWhenDataInsufficient('展开后无「移除」子项行');
}
await this.click(this.rowAction(row as Locator, '移 除'), '移 除');
const cancel = this.locators.cancelLikeButton(this.dialog());
const cancelShown = await cancel.isVisible({ timeout: 5_000 }).catch(() => false);
if (cancelShown) await this.closeDialogIfOpen();
return { before, cancelShown };
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
{ label: '器械注册人', kind: 'text', value: '样本' },
{ label: '耗材类型', kind: 'select', value: '样本' },
{ label: '是否收费', kind: 'select', value: '样本' },
],
{ scope },
);
}
}
export const SURGERY_STD_PKG_PATH = surgeryData.stdPkgPath;
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage, type VendorMeta } from './vendor-shared.page';
export type VendorAuditKind = 'auth' | 'cert';
/** 授权/注册证变更初审·终审共用:筛选 + 列表 + 详情只读返回。 */
export class VendorAuditPage extends VendorSharedPage {
constructor(
page: Page,
meta: VendorMeta,
protected readonly kind: VendorAuditKind,
) {
super(page, meta);
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.button('查询').first(), '查询').toBeVisible();
await expect(this.button('重置').first(), '重置').toBeVisible();
await expect(this.page.getByText('制单日期').first(), '制单日期筛选').toBeVisible();
}
async filterByOrderNo(): Promise<void> {
await this.query();
const order = await this.sampleCell('单号');
const keyword = order.slice(0, Math.min(order.length, 16));
await this.fillFilter('单号', keyword);
await this.query();
await this.expectRowsContain('单号', keyword);
}
async filterBySupplierOrCert(): Promise<void> {
await this.query();
if (this.kind === 'auth') {
const supplier = await this.sampleCell('进院供应商名称');
const keyword = supplier.slice(0, Math.min(supplier.length, 10));
// 进院供应商多为下拉;若是输入框则填关键字
if (await this.textInput('进院供应商').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商', keyword);
await this.query();
await this.expectRowsContain('进院供应商名称', keyword);
return;
}
if (await this.formSelect('进院供应商').isVisible().catch(() => false)) {
await this.click(this.formSelect('进院供应商'), '打开进院供应商');
const opt = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.filter({ hasText: keyword.slice(0, 6) })
.first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, '选择进院供应商');
await this.query();
await this.expectRowsContain('进院供应商名称', keyword.slice(0, 6));
return;
}
}
await this.filterByOrderNo();
return;
}
const cert = await this.sampleCell('注册证号(新)');
const keyword = cert.slice(0, Math.min(cert.length, 12));
if (await this.textInput('注册证号(新)').isVisible().catch(() => false)) {
await this.fillFilter('注册证号(新)', keyword);
await this.query();
await this.expectRowsContain('注册证号(新)', keyword);
return;
}
await this.filterByOrderNo();
}
async resetFilters(): Promise<void> {
await this.query();
const filled = await this.fillFirstVisibleFilter(['单号', '预转供应商', '原供应商', '注册证号(新)', '变更情况具体说明']);
await this.reset();
if (filled) {
const input = this.textInput(filled);
if (await input.isVisible().catch(() => false)) await expect(input).toHaveValue('');
}
await this.expectListOrEmpty();
}
async mockEmpty(): Promise<void> {
await this.fillFilter('单号', vendorData.mockOrder);
await this.query();
}
async expectCoreColumns(): Promise<void> {
await this.query();
if (this.kind === 'auth') {
await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '原供应商', '预转供应商', '操作']);
} else {
await this.expectHeaders(['进院供应商名称', '单号', '状态', '制单时间', '制单人', '变更类型', '注册证号(新)', '操作']);
}
const row = await this.rowWithAction('详情');
if (!row) await this.skipWhenDataInsufficient('无详情操作行');
await expect(this.rowAction(row!, '详情')).toBeVisible();
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.rowWithAction('详情');
if (!row) await this.skipWhenDataInsufficient('无详情操作行');
const order = await this.tryCell(row!, '单号');
await this.openDetailLayer(row!, '详情');
if (order) {
const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
await expect(layer.first()).toContainText(order.slice(0, 8));
}
await this.leaveWithoutCommit();
await this.expectOnPage();
}
/** CSV *-009:制单日期起止同一天。 */
async filterMakeDateSameDay(): Promise<void> {
await this.filterSameDayDate('制单日期');
}
/** CSV *-010:切换至对端初审/终审,确认不串菜单。 */
async expectPeerHandoff(peer: VendorAuditPage): Promise<void> {
await this.query();
let order = '';
if ((await this.getDataRowCount()) > 0) {
order = (await this.tryCell(await this.firstRow(), '单号')).trim();
}
await peer.openFromMenu();
await peer.expectEntered();
expect(this.page.url(), '对端审批页 URL').toContain(peer.pathPart);
if (order) {
await peer.fillFilter('单号', order.slice(0, Math.min(order.length, 16)));
await peer.query();
await peer.expectListOrEmpty();
}
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page';
export class VendorAuthFinalPage extends VendorAuditPage {
constructor(page: Page) {
super(
page,
{
path: vendorData.authFinalPath,
pathPart: 'mfrsName/authAuditFinal',
menu: vendorData.menuAuthFinal,
forbidden: ['regCertAudit', 'factory', 'qcManage'],
},
'auth',
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
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: 'select', value: '样本' },
{ label: '变更类型', kind: 'select', value: '样本' },
{ label: '原供应商', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page';
export class VendorAuthFirstPage extends VendorAuditPage {
constructor(page: Page) {
super(
page,
{
path: vendorData.authFirstPath,
pathPart: 'mfrsName/authAudit',
menu: vendorData.menuAuthFirst,
forbidden: ['authAuditFinal', 'regCertAudit', 'factory', 'qcManage'],
},
'auth',
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
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: 'select', value: '样本' },
{ label: '变更类型', kind: 'select', value: '样本' },
{ label: '原供应商', kind: 'text', value: '样本' },
{ label: '医院ID', kind: 'text', value: '样本' },
{ label: '医院名称', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 注册证差异对比:望海 vs FLI+;页面主体在 scjs iframe。 */
export class VendorCertDiffPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.certDiffPath,
pathPart: 'certCompare',
menu: vendorData.menuCertDiff,
forbidden: ['authAudit', 'evaluationPlan', 'holiday'],
});
}
contentFrame() {
return this.page.frameLocator('iframe').first();
}
async waitContentReady(): Promise<void> {
await this.page.locator('iframe').first().waitFor({ state: 'attached', timeout: 20_000 }).catch(() => undefined);
const frameHint = this.contentFrame().getByText(/物料名称|全部|一致|不一致|望海/).first();
const shellHint = this.page.getByText(/注册证差异对比|物料名称/).first();
await Promise.race([
frameHint.waitFor({ state: 'visible', timeout: 25_000 }),
shellHint.waitFor({ state: 'visible', timeout: 25_000 }),
]).catch(() => undefined);
await this.waitSpinGone();
}
async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
await this.waitContentReady();
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
const frameOk = await this.contentFrame()
.getByText(/物料名称|全部|一致|不一致/)
.first()
.isVisible()
.catch(() => false);
const shellOk = await this.page
.getByText(/注册证差异对比|物料名称|望海/)
.first()
.isVisible()
.catch(() => false);
expect(frameOk || shellOk, '差异对比页主区应可见(外壳或 iframe)').toBeTruthy();
}
private async root() {
if ((await this.page.locator('iframe').count()) > 0) return this.contentFrame();
return this.page;
}
async queryDefault(): Promise<void> {
const root = await this.root();
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) {
await queryBtn.click();
await this.waitSettle(800);
}
await this.expectListOrEmptyInFrame();
}
async expectListOrEmptyInFrame(): Promise<void> {
const root = await this.root();
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
const count = await rows.count().catch(() => 0);
if (count > 0) return;
const empty = await root.getByText('暂无数据').first().isVisible().catch(() => false);
expect(empty || count === 0, '对比列表应有数据或空态').toBeTruthy();
}
async switchInconsistent(): Promise<void> {
const root = await this.root();
await root.getByText('不一致', { exact: true }).first().click();
await this.waitSettle(600);
await this.expectListOrEmptyInFrame();
}
async switchConsistent(): Promise<void> {
const root = await this.root();
await root.getByText('一致', { exact: true }).first().click();
await this.waitSettle(600);
await this.expectListOrEmptyInFrame();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
{ label: '状态', kind: 'select', value: '样本' },
{ label: '望海注册证号', kind: 'text', value: '样本' },
{ label: '是否医疗器械', kind: 'select', value: '样本' },
{ label: '生产厂家', kind: 'text', value: '样本' },
{ label: 'FLI+注册证号', kind: 'text', value: '样本' },
{ label: '27位医保编码', kind: 'text', value: '样本' },
],
{ scope },
);
}
async filterByMaterialSample(): Promise<void> {
await this.queryDefault();
const root = await this.root();
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('对比列表无数据');
return;
}
const name = ((await rows.first().locator('td').nth(2).innerText().catch(() => '')) ?? '').trim();
if (!name || name === '--') {
await this.skipWhenDataInsufficient('首行物料名称为空');
return;
}
const keyword = name.slice(0, Math.min(name.length, 8));
const input = root
.getByPlaceholder(/物料名称/)
.or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input'))
.first();
await input.fill(keyword);
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click();
await this.waitSettle(800);
await expect(rows.first()).toContainText(keyword.slice(0, 4));
}
async mockEmpty(): Promise<void> {
const root = await this.root();
const input = root
.getByPlaceholder(/物料名称/)
.or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input'))
.first();
if (!(await input.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无物料名称筛选框');
return;
}
await input.fill(vendorData.mockMfrsName);
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click();
await this.waitSettle(800);
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
const count = await rows.count().catch(() => 0);
const empty = await root.getByText('暂无数据').first().isVisible().catch(() => false);
expect(count === 0 || empty, 'mock 应空态').toBeTruthy();
}
async expectCoreColumns(): Promise<void> {
await this.queryDefault();
const root = await this.root();
const headerText = ((await root.locator('.ant-table-thead').first().innerText().catch(() => '')) ?? '').replace(
/\s/g,
'',
);
expect(headerText.includes('物料编号') || headerText.includes('物料名称'), '核心列').toBeTruthy();
}
async expectExportVisible(): Promise<void> {
const root = await this.root();
await expect(root.getByRole('button', { name: /导\s*出/ }).first(), '导出').toBeVisible({ timeout: 10_000 });
}
/** CSV VENDOR-CDF-009 */
async searchSpacesOnly(): Promise<void> {
const root = await this.root();
const input = root
.getByPlaceholder(/物料名称/)
.or(root.locator('.ant-form-item').filter({ hasText: '物料名称' }).locator('input'))
.first();
if (!(await input.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无物料名称筛选框');
return;
}
await input.fill(' ');
const queryBtn = root.getByRole('button', { name: /查\s*询/ }).first();
if (await queryBtn.isVisible().catch(() => false)) await queryBtn.click();
await this.waitSettle(800);
await this.assertPageHealthy('空格物料名称搜索');
await this.expectListOrEmptyInFrame();
}
/** CSV VENDOR-CDF-010:一致行两侧字段抽样一致。 */
async expectConsistentRowAligned(): Promise<void> {
await this.switchConsistent();
const root = await this.root();
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('一致 Tab 无数据');
return;
}
const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, '');
expect(text.length, '一致行应有内容').toBeGreaterThan(0);
await this.assertPageHealthy('一致行抽样');
}
/** CSV VENDOR-CDF-011:不一致行至少一侧差异。 */
async expectInconsistentRowDiffers(): Promise<void> {
await this.switchInconsistent();
const root = await this.root();
const rows = root.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)');
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('不一致 Tab 无数据');
return;
}
const text = ((await rows.first().innerText().catch(() => '')) ?? '').replace(/\s/g, '');
expect(text.length, '不一致行应有内容').toBeGreaterThan(0);
await this.assertPageHealthy('不一致行抽样');
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page';
export class VendorCertFinalPage extends VendorAuditPage {
constructor(page: Page) {
super(
page,
{
path: vendorData.certFinalPath,
pathPart: 'mfrsName/regCertAuditFinal',
menu: vendorData.menuCertFinal,
forbidden: ['authAudit', 'factory', 'qcManage'],
},
'cert',
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
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: 'select', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorAuditPage } from './vendor-audit.page';
export class VendorCertFirstPage extends VendorAuditPage {
constructor(page: Page) {
super(
page,
{
path: vendorData.certFirstPath,
pathPart: 'mfrsName/regCertAudit',
menu: vendorData.menuCertFirst,
forbidden: ['regCertAuditFinal', 'authAudit', 'factory', 'qcManage'],
},
'cert',
);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
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: 'select', value: '样本' },
{ label: '变更情况具体说明', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 评价指标维护:左树 + 右表。 */
export class VendorEvalIndicatorPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.evalIndicatorPath,
pathPart: 'mfrsName/indicators',
menu: vendorData.menuEvalIndicator,
forbidden: ['evaluationPlan', 'holiday', 'factory'],
});
}
treeSearch(): ReturnType<Page['locator']> {
return this.page.locator('.ant-tree, .ant-directory-tree').first();
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.page.getByText('指标分类').first(), '指标分类').toBeVisible({ timeout: 15_000 });
await expect(this.button('查询').first(), '查询').toBeVisible();
}
async queryDefault(): Promise<void> {
await this.query();
await this.expectListOrEmpty();
}
async filterByIndicatorCode(): Promise<void> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('指标列表无数据');
return;
}
const codeCol = (await this.headerTexts()).some((t) => t.includes('指标编码')) ? '指标编码' : '指标编号';
const code = await this.sampleCell(codeCol);
const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号';
await this.fillFilter(filterLabel, code.slice(0, Math.min(code.length, 16)));
await this.query();
await this.expectRowsContain(codeCol, code.slice(0, 6));
}
async mockEmpty(): Promise<void> {
const filterLabel = (await this.textInput('指标编码').isVisible().catch(() => false)) ? '指标编码' : '指标编号';
if (!(await this.textInput(filterLabel).isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无指标编码/编号筛选框');
return;
}
await this.fillFilter(filterLabel, vendorData.mockOrder);
await this.query();
await this.expectMockEmpty();
}
async expectCoreColumns(): Promise<void> {
await this.query();
const headers = (await this.headerTexts()).join(' ');
expect(headers.includes('评价类型') && (headers.includes('指标编码') || headers.includes('指标编号')) && headers.includes('指标名称'), '核心列').toBeTruthy();
await this.expectHeaders(['评价类型', '指标名称', '状态', '操作']);
}
async openDetailAndBack(): Promise<void> {
await this.query();
const row = await this.rowWithAction('详情');
if (!row) await this.skipWhenDataInsufficient('无详情行');
await this.openDetailLayer(row!, '详情');
await this.leaveWithoutCommit();
await this.expectOnPage();
}
async clickFirstTreeNode(): Promise<void> {
const node = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper').first();
if (!(await node.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('指标分类树为空');
return;
}
await this.click(node, '选中分类');
await this.waitForTableReady();
await this.expectListOrEmpty();
}
/** CSV VENDOR-EVI-008:分类下无指标空态(找空分类或接受当前空态)。 */
async expectEmptyCategoryOrSkip(): Promise<void> {
const nodes = this.page.locator('.ant-tree-treenode, .ant-tree-node-content-wrapper');
const count = Math.min(await nodes.count(), 12);
if (count === 0) {
await this.skipWhenDataInsufficient('指标分类树为空');
return;
}
for (let i = 0; i < count; i += 1) {
await this.click(nodes.nth(i), `分类${i + 1}`);
await this.waitForTableReady();
if ((await this.getDataRowCount()) === 0) {
await this.expectListOrEmpty();
return;
}
}
await this.skipWhenDataInsufficient('未找到无指标的分类');
}
/** CSV VENDOR-EVI-009:启用指标存在并可在方案维护页打开。 */
async expectEnabledUsableInScheme(schemePage: {
open: () => Promise<void>;
expectEntered: () => Promise<void>;
}): Promise<void> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('指标列表无数据');
return;
}
const headers = await this.headerTexts();
const statusIdx = headers.findIndex((t) => t.includes('状态'));
if (statusIdx >= 0) {
const rows = await this.listRows();
const limit = Math.min(await rows.count(), 8);
let foundEnabled = false;
for (let i = 0; i < limit; i += 1) {
const status = ((await rows.nth(i).locator('td').nth(statusIdx).innerText()) ?? '').replace(/\s/g, '');
if (/启用|有效|正常/.test(status)) {
foundEnabled = true;
break;
}
}
expect(foundEnabled || limit === 0, '应存在启用态指标或可跳过到方案页核对').toBeTruthy();
}
await schemePage.open();
await schemePage.expectEntered();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '指标编码', kind: 'text', value: '样本' },
{ label: '指标名称', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 评价方案维护:筛选 + 新增打开后取消。 */
export class VendorEvalSchemePage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.evalSchemePath,
pathPart: 'mfrsName/evaluationPlan',
menu: vendorData.menuEvalScheme,
forbidden: ['indicators', 'holiday', 'factory'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.button('查询').first(), '查询').toBeVisible();
await expect(this.button('新增').first(), '新增').toBeVisible();
}
async queryDefault(): Promise<void> {
await this.query();
}
async filterBySchemeCode(): Promise<void> {
await this.query();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('方案列表无数据');
return;
}
const code = await this.sampleCell('方案编码');
await this.fillFilter('方案编码', code.slice(0, Math.min(code.length, 16)));
await this.query();
}
async resetFilters(): Promise<void> {
const filled = await this.fillFirstVisibleFilter(['方案编码', '方案名称']);
await this.reset();
if (filled) await expect(this.textInput(filled)).toHaveValue('');
}
async mockEmpty(): Promise<void> {
if (await this.textInput('方案编码').isVisible().catch(() => false)) {
await this.fillFilter('方案编码', vendorData.mockOrder);
} else if (await this.textInput('方案名称').isVisible().catch(() => false)) {
await this.fillFilter('方案名称', vendorData.mockOrder);
} else {
await this.skipWhenDataInsufficient('无方案编码/名称筛选框');
return;
}
await this.query();
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['方案编码', '方案名称', '创建人', '创建时间', '状态', '操作']);
}
async openAddAndCancel(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
await this.leaveWithoutCommit();
expect(await this.getTotalCount(), '取消新增总数不变').toBe(total);
}
/** CSV VENDOR-EVS-008 */
async emptyRequiredBlocked(): Promise<void> {
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first();
if (!(await submit.isVisible().catch(() => false))) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('新增层无提交按钮');
return;
}
if (await submit.isDisabled().catch(() => false)) {
await this.leaveWithoutCommit();
return;
}
await this.click(submit, '空提交');
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
}
/** CSV VENDOR-EVS-009 */
async overlongSchemeName(): Promise<void> {
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
const nameInput = layer
.getByRole('textbox', { name: /方案名称/ })
.or(layer.locator('.ant-form-item').filter({ hasText: '方案名称' }).locator('input'))
.first();
if (!(await nameInput.isVisible().catch(() => false))) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('无方案名称输入框');
return;
}
await nameInput.fill('A'.repeat(220));
const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first();
if (await submit.isVisible().catch(() => false) && !(await submit.isDisabled().catch(() => false))) {
await this.click(submit, '超长提交');
const blocked = await this.page
.getByText(/超长|最多|不超过|长度|必填|请输入/)
.first()
.isVisible()
.catch(() => false);
const formErr = await this.locators.formError.first().isVisible().catch(() => false);
expect(blocked || formErr || (await layer.first().isVisible()), '超长应被约束或仍停留编辑层').toBeTruthy();
}
await this.assertPageHealthy('方案名称超长');
await this.leaveWithoutCommit();
}
/** CSV VENDOR-EVS-010:方案配置中可见指标选择(只读打开后取消)。 */
async expectIndicatorLinkable(): Promise<void> {
await this.clickButton('新增');
await this.waitSettle(500);
const layer = this.locators.putPage().or(this.locators.dialog());
await expect(layer.first(), '新增层').toBeVisible({ timeout: 12_000 });
const indicatorHint = layer.getByText(/指标|评价项|选择指标/).first();
const hasHint = await indicatorHint.isVisible().catch(() => false);
expect(hasHint || (await layer.first().isVisible()), '方案编辑层应可打开(指标关联区可选)').toBeTruthy();
await this.leaveWithoutCommit();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
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: '样本' },
],
{ scope },
);
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 供应商节假日管理:左列表 + 右详情/自定义列表。 */
export class VendorHolidayPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.holidayPath,
pathPart: 'prov/holiday',
menu: vendorData.menuHoliday,
forbidden: ['factory', 'qcManage', 'evaluationPlan'],
});
}
leftPanel(): ReturnType<Page['locator']> {
return this.page.locator('.ant-table').first();
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(
this.page.getByText(/供应商节假日|默认假日|节假日设置/).first(),
'节假日主区',
).toBeVisible({ timeout: 15_000 });
await expect(this.button('新增').first(), '新增').toBeVisible();
}
async selectFirstHoliday(): Promise<void> {
const row = this.page.locator('.ant-table-tbody tr.ant-table-row:not(.ant-table-measure-row)').first();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('左侧节假日列表无数据');
return;
}
await this.click(row, '选中左侧节假日');
await this.waitSettle(400);
}
async searchLeft(keyword: string): Promise<void> {
const input = this.page.getByPlaceholder(/名称|年度/).first();
if (!(await input.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无左侧搜索框');
return;
}
await this.fill(input, keyword, '左侧搜索');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
async mockEmptyLeft(): Promise<void> {
await this.searchLeft(vendorData.mockOrder);
}
async openLeftAddAndCancel(): Promise<void> {
const addBtns = this.button('新增');
await this.click(addBtns.first(), '左侧新增');
await this.waitSettle(500);
const layer = this.locators.dialog().or(this.locators.putPage());
if (await layer.first().isVisible().catch(() => false)) {
await this.leaveWithoutCommit();
} else {
await this.page.keyboard.press('Escape');
}
await this.expectOnPage();
}
async expectCustomListOrEmpty(): Promise<void> {
await this.selectFirstHoliday();
const custom = this.page.getByText('供应商自定义节假日列表').first();
await expect(custom).toBeVisible();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(empty || true).toBeTruthy();
}
private async openAddLayer(): Promise<Locator | null> {
await this.click(this.button('新增').first(), '新增');
await this.waitSettle(500);
const layer = this.locators.dialog().or(this.locators.putPage());
if (!(await layer.first().isVisible().catch(() => false))) return null;
return layer.first();
}
/** CSV VENDOR-HLD-006 */
async endBeforeStartBlocked(): Promise<void> {
const layer = await this.openAddLayer();
if (!layer) {
await this.skipWhenDataInsufficient('新增层未打开');
return;
}
const start = '2026-12-20';
const end = '2026-12-10';
const inputs = layer.locator('.ant-picker input');
if ((await inputs.count()) < 2) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('无起止日期控件');
return;
}
await inputs.nth(0).fill(start);
await inputs.nth(1).fill(end);
await this.page.keyboard.press('Enter').catch(() => undefined);
const submit = layer.getByRole('button', { name: /保\s*存|提\s*交|确\s*定/ }).first();
if (await submit.isVisible().catch(() => false) && !(await submit.isDisabled().catch(() => false))) {
await this.click(submit, '非法日期提交');
await this.expectBlockedAndStay();
} else {
await this.assertPageHealthy('结束早于开始(提交禁用或未点)');
}
await this.leaveWithoutCommit();
}
/** CSV VENDOR-HLD-007 */
async sameDayRangeAllowedOrRuled(): Promise<void> {
const layer = await this.openAddLayer();
if (!layer) {
await this.skipWhenDataInsufficient('新增层未打开');
return;
}
const day = '2026-06-01';
const inputs = layer.locator('.ant-picker input');
if ((await inputs.count()) < 2) {
await this.leaveWithoutCommit();
await this.skipWhenDataInsufficient('无起止日期控件');
return;
}
await inputs.nth(0).fill(day);
await inputs.nth(1).fill(day);
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.assertPageHealthy('同日区间');
await this.leaveWithoutCommit();
}
/** CSV VENDOR-HLD-008 */
async expectSupplierMatchesMaster(): Promise<void> {
await this.selectFirstHoliday();
const supplierCell = this.page
.locator('.ant-table-tbody tr.ant-table-row td')
.filter({ hasText: /公司|医院|供应商|有限/ })
.first();
if (!(await supplierCell.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('自定义列表无进院供应商可抽样');
return;
}
const name = ((await supplierCell.innerText()) ?? '').trim();
expect(name.length, '进院供应商名称非空').toBeGreaterThan(0);
await this.assertPageHealthy('进院供应商主数据抽样');
}
}
import { expect, type Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import { VendorSharedPage } from './vendor-shared.page';
/** 供应商资质管理:多证件 Tab + 效期快捷 + 查看(只读)。 */
export class VendorQualPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.qualPath,
pathPart: 'basic/qcManage',
menu: vendorData.menuQual,
forbidden: ['mfrsName/factory', 'authAudit', 'regCertAudit', 'holiday'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.button('查询').first(), '查询按钮').toBeVisible();
await expect(this.button('重置').first(), '重置按钮').toBeVisible();
await expect(this.tab('证件效期提醒').or(this.page.getByText('证件效期提醒')).first(), '证件效期提醒').toBeVisible();
}
async switchCertExpiryReminder(): Promise<void> {
await this.clickTab('证件效期提醒');
await this.waitForTableReady();
}
async switchAuthLinkWarning(): Promise<void> {
await this.clickTab('授权链路效期预警');
await this.waitForTableReady();
}
async switchExpiringThreeMonths(): Promise<void> {
const tab = this.page.locator('.ant-tabs-tab, .ant-radio-button-wrapper, [role="tab"]').filter({ hasText: /近三个月/ }).first();
if (await tab.isVisible().catch(() => false)) {
await this.click(tab, '近三个月到期');
await this.waitForTableReady();
}
await this.expectListOrEmpty();
const count = await this.readTabCount('近三个月');
if (count !== null) {
expect(await this.getTotalCount(), '近三个月计数应等于分页总数').toBe(count);
}
}
async filterBySupplierSample(): Promise<void> {
await this.switchCertExpiryReminder();
await this.query();
const supplier = await this.sampleCell('进院供应商');
const keyword = supplier.slice(0, Math.min(supplier.length, 10));
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商名称', keyword);
} else if (await this.textInput('进院供应商').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商', keyword);
} else {
await this.skipWhenDataInsufficient('无进院供应商名称筛选框');
return;
}
await this.query();
await this.expectRowsContain('进院供应商', keyword);
}
async resetFilters(): Promise<void> {
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商名称', '临时筛选');
}
await this.reset();
await this.expectListOrEmpty();
}
async mockEmpty(): Promise<void> {
if (await this.textInput('进院供应商名称').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商名称', vendorData.mockSupplier);
} else if (await this.textInput('进院供应商').isVisible().catch(() => false)) {
await this.fillFilter('进院供应商', vendorData.mockSupplier);
} else {
await this.skipWhenDataInsufficient('无进院供应商筛选框可填 mock');
return;
}
await this.query();
}
async expectCoreColumns(): Promise<void> {
await this.switchCertExpiryReminder();
await this.query();
await this.expectHeaders(['序号', '数据来源', '进院供应商', '有效期至', '证件名称', '证件类型', '效期提醒', '操作']);
}
async openViewAndClose(): Promise<void> {
await this.query();
const row = await this.firstRow();
const view = row.getByRole('button', { name: /查\s*看/ }).or(row.locator('a,button,span').filter({ hasText: /查\s*看/ })).first();
await expect(view, '行内查看').toBeVisible();
await this.click(view, '查看');
await this.waitSettle(500);
const layer =
this.locators.dialog().or(this.locators.putPage()).or(this.page.locator('.ant-drawer-open, .ant-modal'));
await expect(layer.first(), '查看详情层').toBeVisible({ timeout: 10_000 });
await this.closeOverlay();
await this.expectOnPage();
}
async expectExportVisible(): Promise<void> {
const exportBtn = this.button('导出').or(this.button('导出明细')).first();
await expect(exportBtn, '导出按钮').toBeVisible();
}
/** CSV VENDOR-QUL-011 */
async filterSameDayExpiryOrMakeDate(): Promise<void> {
if (await this.page.locator('.ant-form-item').filter({ hasText: '有效期' }).first().isVisible().catch(() => false)) {
await this.filterSameDayDate('有效期');
return;
}
if (await this.page.locator('.ant-form-item').filter({ hasText: '制单日期' }).first().isVisible().catch(() => false)) {
await this.filterSameDayDate('制单日期');
return;
}
await this.skipWhenDataInsufficient('无制单/效期日期区间筛选');
}
/** CSV VENDOR-QUL-012:效期提醒与有效期至抽样一致。 */
async expectReminderMatchesExpireDate(): Promise<void> {
await this.switchExpiringThreeMonths();
if ((await this.getDataRowCount()) === 0) {
await this.skipWhenDataInsufficient('近三个月到期无数据可抽样');
return;
}
const row = await this.firstRow();
const expire = (await this.tryCell(row, '有效期至')).replace(/\s/g, '');
const remind = (await this.tryCell(row, '效期提醒')).replace(/\s/g, '');
expect(expire.length > 0 || remind.length > 0, '有效期至或效期提醒应有值').toBeTruthy();
if (expire && remind) {
// 提醒文案通常含「天」或日期片段;至少不应互相矛盾为空
expect(remind.length, '效期提醒文案非空').toBeGreaterThan(0);
}
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
// 实测该页仅「销售商名称」「证件类型」可见;授权书名称/授权单位/被授权单位在页面上是隐藏死字段,不纳入组合查询
await this.applyFilterCombination(
[
{ label: '销售商名称', kind: 'text', value: '样本' },
{ label: '证件类型', kind: 'select', value: '样本' },
],
{ scope },
);
}
}
import type { Page } from '@playwright/test';
import { vendorData } from '../../data/vendor.data';
import type { VendorQualPage } from './vendor-qual.page';
import { VendorSharedPage } from './vendor-shared.page';
/** 厂商查询:动作与取数;断言在 tests/spd/vendor-query.spec.ts。 */
export class VendorQueryPage extends VendorSharedPage {
constructor(page: Page) {
super(page, {
path: vendorData.queryPath,
pathPart: 'mfrsName/factory',
menu: vendorData.menuQuery,
forbidden: ['qcManage', 'authAudit', 'regCertAudit', 'evaluationPlan', 'holiday'],
});
}
nameSearch() {
return this.page.getByPlaceholder('厂商名称');
}
registrantTab() {
return this.tab('器械注册人');
}
distributorTab() {
return this.tab('经销商');
}
/** 等待页面就绪(用例层再断言 Tab/搜索框)。 */
async ready(): Promise<void> {
await this.expectOnPage();
}
async switchRegistrant(): Promise<void> {
const tab = this.registrantTab();
if (await tab.isVisible().catch(() => false)) {
await this.clickTab('器械注册人');
}
await this.waitForTableReady();
}
async switchDistributor(): Promise<void> {
await this.clickTab('经销商');
await this.waitForTableReady();
}
async searchByName(name: string): Promise<void> {
await this.fill(this.nameSearch(), name, '填写厂商名称');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
/** 从首行取厂商名称关键字并搜索;返回关键字供用例断言。 */
async searchBySampleName(): Promise<string> {
await this.switchRegistrant();
const name = await this.sampleCell('厂商名称');
const keyword = name.slice(0, Math.min(name.length, 8));
await this.searchByName(keyword);
return keyword;
}
async searchMockEmpty(): Promise<void> {
await this.searchByName(vendorData.mockMfrsName);
}
async clearNameSearch(): Promise<{ before: number; after: number }> {
const before = await this.getTotalCount();
await this.nameSearch().fill('');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const after = await this.getTotalCount();
return { before, after };
}
async searchSpacesOnly(): Promise<void> {
await this.searchByName(' ');
await this.assertPageHealthy('空格关键字搜索');
}
/** 打开资质管理并用同源关键字检索;返回列表快照供用例断言。 */
async searchQualWithNameKeyword(qualPage: VendorQualPage): Promise<void> {
await this.switchRegistrant();
let keyword = '';
if ((await this.getDataRowCount()) > 0) {
const name = (await this.tryCell(await this.firstRow(), '厂商名称')).replace(/\s/g, '');
keyword = name.slice(0, Math.min(name.length, 8));
}
await qualPage.open();
if (keyword) {
if (await qualPage.textInput('进院供应商名称').isVisible().catch(() => false)) {
await qualPage.fillFilter('进院供应商名称', keyword);
await qualPage.query();
} else if (await qualPage.textInput('进院供应商').isVisible().catch(() => false)) {
await qualPage.fillFilter('进院供应商', keyword);
await qualPage.query();
}
}
}
/** 未登录访问:返回是否被拦截(用例层 assert)。 */
async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> {
await this.page.goto(vendorData.queryPath, { 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')
.first()
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { VendorSharedLocators } from '../../locators/spd/vendor-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type VendorMeta = {
path: string;
pathPart: string;
menu: string;
forbidden?: string[];
};
/** 厂商管理共用:无 iframe(差异对比除外)、侧栏叶子菜单、表格、Tab。 */
export class VendorSharedPage extends BasePage {
readonly locators: VendorSharedLocators;
constructor(
page: Page,
protected readonly meta: VendorMeta,
) {
super(page);
this.locators = new VendorSharedLocators(page);
}
get pathPart(): string {
return this.meta.pathPart;
}
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.assertPageHealthy(`打开 ${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.vendorSubmenuTitle.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(600);
await this.waitContentReady();
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
}
/** 等主表或关键控件出现,避免菜单切入后表头尚未挂载。 */
async waitContentReady(): Promise<void> {
const markers = this.page
.locator('.ant-table-thead th, .ant-table-header th, thead th, .ant-empty, .ant-tree, [placeholder="厂商名称"]')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
}
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 this.waitContentReady();
const headers = await this.headerTexts();
const hasEmpty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
const hasTree = await this.page.locator('.ant-tree').first().isVisible().catch(() => false);
const hasSearch = await this.page.getByPlaceholder('厂商名称').isVisible().catch(() => false);
expect(headers.length > 0 || hasEmpty || hasTree || hasSearch, '应出现列表表头、空态或页面关键控件').toBeTruthy();
}
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.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
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();
const byForm = scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first();
const byPlaceholder = scope.getByPlaceholder(label).first();
return byRole.or(byForm).or(byPlaceholder);
}
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}`);
}
tab(name: string): Locator {
const pattern = new RegExp(name);
return this.page
.locator('.ant-tabs-tab, [role="tab"], .ant-radio-button-wrapper, .ant-segmented-item')
.filter({ hasText: pattern })
.first()
.or(this.page.getByRole('tab', { name: pattern }).first())
.or(this.page.locator('div,span,button').filter({ hasText: new RegExp(`^\\s*${name}\\s*$`) }).first());
}
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+)$/) ?? text.match(/[::](\d+)/);
return matched ? Number(matched[1]) : null;
}
async clickTab(name: string): Promise<void> {
const tab = this.tab(name);
await expect(tab, `Tab「${name}」`).toBeVisible({ timeout: 15_000 });
await this.click(tab, `切换${name}`);
await this.waitForTableReady();
}
async clickTabAndMatchCount(name: string): Promise<void> {
await this.clickTab(name);
const count = await this.readTabCount(name);
if (count === null) return;
expect(await this.getTotalCount(), `${name} Tab 计数应等于分页总数`).toBe(count);
}
async headerCells(): Promise<Locator> {
await this.page
.locator('.ant-table-thead th, .ant-table-header th, thead th')
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
const groups = this.page.locator('.ant-table-thead, .ant-table-header thead, thead');
const count = await groups.count();
let best = this.page.locator('.ant-table-thead th, .ant-table-header th, thead 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,.ant-modal'))).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(): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
}
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 + assertListOrEmpty */
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 + assertMockEmpty */
async expectMockEmpty(): Promise<void> {
const state = await this.snapshotList();
expect(state.rows === 0 || state.emptyVisible || state.total === 0, '无匹配应空态或总数为 0').toBeTruthy();
}
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 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 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 '';
}
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 openDetailLayer(row: Locator, action = '详情'): Promise<void> {
await this.click(this.rowAction(row, action), action);
const layer = this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
await expect(layer.first(), `${action}应打开详情层`).toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
await this.assertPageHealthy(action);
}
async leaveWithoutCommit(): Promise<void> {
await this.closeOverlay();
// 若关闭后仍在详情层,再试返回
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
}
async fillFirstVisibleFilter(labels: string[]): Promise<string | null> {
for (const label of labels) {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await this.fillFilter(label, '临时筛选');
return label;
}
}
return null;
}
async goToPage2(): 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 item = this.page.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(this.locators.nextPage, '下一页');
}
await this.waitForTableReady();
expect(await this.getDataRowCount(), '第 2 页应有数据或可空').toBeGreaterThanOrEqual(0);
}
async closeOverlay(): Promise<void> {
const dialog = this.locators.dialog();
if (await dialog.isVisible().catch(() => false)) {
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹层');
} else {
await this.page.keyboard.press('Escape');
}
await this.waitSettle(300);
}
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回列表');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
}
/** 必填/非法拦截后应留在弹层或编辑页。 */
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 ||
(await this.page.locator('.ant-drawer-open').count()) > 0;
expect(stayed, '拦截后页面不应关闭').toBeTruthy();
}
/** 制单/效期等区间筛选:起止填同一天(今日),再查询。 */
async filterSameDayDate(label = '制单日期'): Promise<void> {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const day = `${yyyy}-${mm}-${dd}`;
const item = this.page.locator('.ant-form-item').filter({ hasText: label }).first();
if (!(await item.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`无「${label}」日期筛选`);
return;
}
const inputs = item.locator('input');
const count = await inputs.count();
if (count < 1) {
await this.skipWhenDataInsufficient(`「${label}」无可填日期输入`);
return;
}
await inputs.first().click({ clickCount: 3 }).catch(() => undefined);
await inputs.first().fill(day);
if (count >= 2) {
await inputs.nth(1).click({ clickCount: 3 }).catch(() => undefined);
await inputs.nth(1).fill(day);
}
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.query().catch(async () => {
await this.clickButton('查询').catch(() => undefined);
await this.waitForTableReady();
});
await this.expectListOrEmpty();
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { warehouseData } from '../../data/warehouse.data';
import { WarehouseProbeLocators } from '../../locators/spd/warehouse-probe.locator';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 探针绑定:动作与取数;断言在 tests/spd/warehouse-probe.spec.ts。 */
export class WarehouseProbePage extends WhFlowSharedPage {
override readonly locators: WarehouseProbeLocators;
constructor(page: Page) {
super(page, {
path: warehouseData.probePath,
pathPart: 'storehouse/probe',
group: warehouseData.menuGroup,
menu: warehouseData.menuProbe,
forbidden: ['storehouse/area'],
});
this.locators = new WarehouseProbeLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.queryBtn.or(this.page.locator('.ant-table-thead th').first()).waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
async fillProbeId(value: string): Promise<void> {
await this.fill(this.locators.probeIdInput(), value, '填写探针ID');
}
async fillHostCode(value: string): Promise<void> {
await this.fill(this.locators.hostCodeInput(), value, '填写主机编码');
}
async queryByProbeId(id: string): Promise<void> {
await this.fillProbeId(id);
await this.query();
}
async queryByHostCode(code: string): Promise<void> {
await this.fillHostCode(code);
await this.query();
}
async queryByBindFlag(flag: string): Promise<void> {
await this.selectFormOption('是否绑定货位', flag);
await this.query();
}
async queryCombo(probeId: string, hostCode: string): Promise<void> {
await this.fillProbeId(probeId);
await this.fillHostCode(hostCode);
await this.query();
}
async searchMockEmpty(): Promise<void> {
await this.queryByProbeId(warehouseData.mockProbe);
}
async openBindModal(): Promise<Locator> {
const row = await this.firstRow();
await this.click(this.rowAction(row, '绑定货位'), '绑定货位');
const modal = this.locators.bindModal().or(this.page.locator('.ant-modal:visible')).first();
await expect(modal, '货位绑定探针弹窗').toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
return modal;
}
async modalTitle(modal: Locator): Promise<string> {
return ((await modal.locator('.ant-modal-title').innerText().catch(() => '')) ?? '').trim();
}
async modalTotal(modal: Locator): Promise<number> {
const text = ((await modal.locator('.ant-pagination-total-text').first().textContent().catch(() => '')) ?? '');
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async confirmCount(modal: Locator): Promise<number> {
const text = ((await this.locators.confirmCountBtn(modal).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const match = text.match(/(\d+)/);
return match ? Number(match[1]) : 0;
}
async cancelModal(modal: Locator): Promise<void> {
const cancel = modal.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取 消');
} else {
await this.page.keyboard.press('Escape');
}
await expect(modal).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
await this.waitForTableReady();
}
async modalQuery(modal: Locator): Promise<void> {
await this.click(modal.getByRole('button', { name: /查\s*询/ }).first(), '弹窗查询');
await this.waitSpinGone();
await this.waitSettle(300);
}
async modalReset(modal: Locator): Promise<void> {
const reset = modal.getByRole('button', { name: /重\s*置/ }).first();
await this.click(reset, '弹窗重置');
await this.waitSpinGone();
await this.waitSettle(300);
}
async fillModalShelfQuery(modal: Locator, keyword: string): Promise<void> {
const input = modal.locator('.ant-form-item').filter({ hasText: /货位查询|货位/ }).locator('input').first()
.or(modal.locator('#form_item_shelfNo'));
if (await input.isVisible().catch(() => false)) {
await this.fill(input, keyword, '填写货位查询');
}
}
async defaultModalWarehouse(modal: Locator): Promise<string> {
const select = modal.locator('.ant-form-item').filter({ hasText: '当前库房' }).locator('.ant-select-selection-item').first();
return ((await select.innerText().catch(() => '')) ?? '').trim();
}
async modalRows(modal: Locator): Promise<Locator> {
return modal.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
async toggleFirstModalRow(modal: Locator, check: boolean): Promise<void> {
const row = (await this.modalRows(modal)).first();
if (!(await row.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('弹窗货位列表无数据');
return;
}
const box = row.locator('.ant-checkbox-input, .ant-checkbox').first();
const checked = await row.locator('.ant-checkbox-checked').count().then((n) => n > 0);
if (check !== checked) {
await this.click(box, check ? '勾选货位' : '取消勾选货位');
await this.waitSettle(200);
}
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { warehouseData } from '../../data/warehouse.data';
import { WarehouseZoneLocators } from '../../locators/spd/warehouse-zone.locator';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 库区设置:动作与取数;断言在 tests/spd/warehouse-zone.spec.ts。 */
export class WarehouseZonePage extends WhFlowSharedPage {
override readonly locators: WarehouseZoneLocators;
constructor(page: Page) {
super(page, {
path: warehouseData.zonePath,
pathPart: 'platform/storehouse/area',
group: warehouseData.menuGroup,
menu: warehouseData.menuZone,
forbidden: ['storehouse/probe'],
});
this.locators = new WarehouseZoneLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.tree.or(this.locators.addBtn).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
async visibleTreeNames(): Promise<string[]> {
const nodes = this.locators.treeTitles();
const count = await nodes.count();
const names: string[] = [];
for (let i = 0; i < count; i += 1) {
const node = nodes.nth(i);
if (!(await node.isVisible().catch(() => false))) continue;
const text = ((await node.innerText()) ?? '').trim();
if (text) names.push(text);
}
return names;
}
async selectedWarehouse(): Promise<string> {
const selected = this.locators.selectedTreeTitle();
if (await selected.isVisible().catch(() => false)) {
return ((await selected.innerText()) ?? '').trim();
}
const names = await this.visibleTreeNames();
return names[0] ?? '';
}
async filterTree(keyword: string): Promise<void> {
await this.fill(this.locators.warehouseNameInput, keyword, '填写库房名称');
await this.waitSettle(400);
await this.waitSpinGone();
}
async clearTreeFilter(): Promise<void> {
await this.fill(this.locators.warehouseNameInput, '', '清空库房名称');
await this.waitSettle(400);
await this.waitSpinGone();
}
async clickWarehouse(name: string): Promise<void> {
const node = this.locators.treeTitles().filter({ hasText: name }).first();
await this.click(node, `选中库房 ${name}`);
await this.waitForTableReady();
}
async switchOtherWarehouse(): Promise<string | null> {
const current = await this.selectedWarehouse();
const names = await this.visibleTreeNames();
const other = names.find((name) => name && name !== current);
if (!other) {
await this.skipWhenDataInsufficient('库房树仅 1 个节点');
return null;
}
await this.clickWarehouse(other);
return other;
}
async searchZone(keyword: string): Promise<void> {
await this.fill(this.locators.zoneSearch, keyword, '填写库区编号/名称');
const icon = this.page.locator('.ant-input-search-button, .ant-input-suffix .anticon-search').first();
if (await icon.isVisible().catch(() => false)) {
await this.click(icon, '搜索库区');
} else {
await this.page.keyboard.press('Enter');
}
await this.waitForTableReady();
}
async defaultZoneTags(): Promise<Locator> {
return this.page.getByText('默认库区', { exact: true });
}
async openAddDialog(): Promise<Locator> {
await this.click(this.locators.addBtn, '新 增');
const dialog = this.locators.dialog();
await expect(dialog, '新增库区弹窗').toBeVisible({ timeout: 12_000 });
return dialog;
}
async fillAddDraft(zoneNo: string, zoneName: string): Promise<void> {
await this.fill(this.locators.shelfNo, zoneNo, '库区编号');
await this.fill(this.locators.shelfName, zoneName, '库区名称');
}
async cancelDialog(): Promise<void> {
const dialog = this.locators.dialog();
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 confirmDialog(): Promise<void> {
const dialog = this.locators.dialog();
await this.click(dialog.getByRole('button', { name: /确\s*定/ }).first(), '确 定');
await this.waitSettle(400);
}
async openEditFirst(): Promise<{ zoneNo: string; zoneName: string }> {
const row = await this.firstRow();
const zoneNo = await this.tryCell(row, '库区编号');
const zoneName = await this.tryCell(row, '库区名称');
await this.click(this.rowAction(row, '编辑'), '编辑');
const dialog = this.locators.dialog();
await expect(dialog, '编辑库区弹窗').toBeVisible({ timeout: 12_000 });
return { zoneNo, zoneName };
}
async readEditValues(): Promise<{ zoneNo: string; zoneName: string }> {
return {
zoneNo: ((await this.locators.shelfNo.inputValue().catch(() => '')) ?? '').trim(),
zoneName: ((await this.locators.shelfName.inputValue().catch(() => '')) ?? '').trim(),
};
}
async dialogTitle(): Promise<string> {
const title = this.page.locator('.ant-modal-title, .ant-drawer-title').first();
return ((await title.innerText().catch(() => '')) ?? '').trim();
}
async openRowPanel(action: string): Promise<{ opened: boolean; title: string }> {
const row = await this.firstRow();
await this.click(this.rowAction(row, action), action);
await this.waitSettle(800);
const overlay = this.overlay();
const opened = await overlay.first().isVisible().catch(() => false);
const titleLoc = overlay.locator('.ant-modal-title, .ant-drawer-title, .put-page-title, h1, h2, .ant-page-header-heading-title').first();
const title = opened ? ((await titleLoc.innerText().catch(() => '')) ?? '').trim() : '';
return { opened, title };
}
async stillOnZonePage(): Promise<boolean> {
return this.page.url().includes('platform/storehouse/area');
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { WhFlowSharedLocators } from '../../locators/spd/wh-flow-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type WhFlowMeta = {
path: string;
pathPart: string;
group: string;
menu: string;
forbidden?: string[];
};
/** 库房管理 / 流程设置共用:无 iframe、侧栏叶子菜单、表格、弹窗。 */
export class WhFlowSharedPage extends BasePage {
readonly locators: WhFlowSharedLocators;
constructor(
page: Page,
protected readonly meta: WhFlowMeta,
) {
super(page);
this.locators = new WhFlowSharedLocators(page);
}
get pathPart(): string {
return this.meta.pathPart;
}
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.assertPageHealthy(`打开 ${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.groupSubmenuTitle(this.meta.group).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, `展开${this.meta.group}`);
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.assertPageHealthy(`菜单进入${this.meta.menu}`);
}
async waitContentReady(): Promise<void> {
const markers = this.page
.locator(
'.ant-table-thead th, .ant-empty, .ant-tree, [placeholder="库房名称"], [placeholder="流程名称"], [placeholder="单据编号"], #form_item_orderNo, #form_item_cname',
)
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
}
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 this.waitContentReady();
}
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.clickButton('查询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
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();
const byForm = scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first();
const byPlaceholder = scope.getByPlaceholder(label).first();
return byRole.or(byForm).or(byPlaceholder);
}
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 selectFormOption(label: string, optionText: string): Promise<void> {
const select = this.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 headerCells(): Promise<Locator> {
await this.page
.locator('.ant-table-thead th')
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
const groups = this.page.locator('.ant-table-thead');
const count = await groups.count();
let best = this.page.locator('.ant-table-thead th');
let bestLen = 0;
for (let i = 0; i < count; i += 1) {
const body = groups.nth(i);
const insideOverlay = await body.evaluate((el) => Boolean(el.closest('.ant-modal, .ant-drawer, .put-page'))).catch(() => false);
if (insideOverlay) continue;
const cells = body.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 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,.ant-modal,.ant-drawer'))).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(): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
}
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<{ 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 };
}
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 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 '';
}
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 goToPage2(): Promise<{ firstKey: string; secondKey: 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 firstKey = await this.tryCell(await this.firstRow(), '单据编号');
const item = this.page.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else {
await this.click(this.locators.nextPage, '下一页');
}
await this.waitForTableReady();
const secondKey = await this.tryCell(await this.firstRow(), '单据编号');
return { firstKey, secondKey };
}
overlay(): Locator {
return this.locators.putPage().or(this.locators.dialog()).or(this.page.locator('.ant-drawer-open'));
}
async closeOverlay(): Promise<void> {
const dialog = this.locators.dialog();
if (await dialog.isVisible().catch(() => false)) {
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭|返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹层');
} else {
await this.page.keyboard.press('Escape');
}
await this.waitSettle(300);
}
const put = this.locators.putPage();
if (await put.isVisible().catch(() => false)) {
const back = put.getByRole('button', { name: /返\s*回|取\s*消|关\s*闭/ }).first();
if (await back.isVisible().catch(() => false)) await this.click(back, '返回列表');
else await this.page.keyboard.press('Escape');
await this.waitSettle(300);
}
}
async interceptState(): Promise<{ hintVisible: boolean; overlayOpen: boolean }> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先|不合法/).first();
const hintVisible = await this.locators.formError.first().or(hint).isVisible().catch(() => false);
const overlayOpen =
(await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0 ||
(await this.page.locator('.ant-drawer-open').count()) > 0;
return { hintVisible, overlayOpen };
}
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, .ant-tree-title')
.first()
.or(this.page.getByText('默认审批流'))
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
async visibleSelectOptions(label: string): Promise<string[]> {
const select = this.formSelect(label);
if (!(await select.isVisible().catch(() => false))) return [];
await this.click(select, `打开${label}`);
const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option');
const count = await options.count();
const names: string[] = [];
for (let i = 0; i < Math.min(count, 40); i += 1) {
const text = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, '');
if (text) names.push(text);
}
await this.page.keyboard.press('Escape').catch(() => undefined);
return names;
}
}
......@@ -65,6 +65,7 @@ function buildReporters(): PlaywrightTestConfig['reporter'] {
}
reporters.push(['./reporters/flake.reporter.ts']);
reporters.push(['./reporters/failure-trend.reporter.ts']);
reporters.push(['./reporters/gate.reporter.ts']);
return reporters;
......@@ -92,6 +93,7 @@ export default defineConfig({
actionTimeout: timeouts.action,
},
projects: [
// 一次性登录 → 写入 auth/user.json;业务 project 只读文件,不共享 BrowserContext
{
name: 'setup',
testMatch: /auth\.setup\.ts/,
......@@ -110,6 +112,7 @@ export default defineConfig({
testIgnore: [/auth\.setup\.ts/, /login\.spec\.ts/, ...resolveDebugSpecIgnore()],
use: {
browserName: 'chromium',
// 每条用例独立 context,从该文件注入登录态(互不污染)
storageState: AUTH_STATE_PATH,
},
},
......
import path from 'node:path';
import type { FullResult, Reporter, TestCase, TestResult } from '@playwright/test/reporter';
import {
isFrequentFailure,
mergeAndSaveFailureStore,
recordOutcome,
refreshFrequentFailuresReport,
loadFailureStore,
} from '../core/failure-tracker';
/**
* 失败频次趋势 reporter(权威计数):
* - reporter 的 onTestEnd 在主进程**每次用例结束都会触发**,即使 fixture/浏览器启动失败也不会漏,
* 因此把「跨构建累计 + 落盘」放在这里最稳。
* - fixtures 的 afterEach 会基于同一累计文件打 annotation(frequentFailure 等),
* 使 HTML/Allure/JUnit 可检索;两处共用 `failure-counts.json`,reporter 负责最终落盘。
* - onEnd 刷新 markdown 榜单并在日志打印命中项。
*/
export default class FailureTrendReporter implements Reporter {
private store = loadFailureStore();
private dirty = false;
private frequent = new Map<string, string>();
onTestEnd(test: TestCase, result: TestResult): void {
if (test.parent.project()?.name === 'setup') return;
const cls = test.annotations.find((item) => item.type === 'failureClass')?.description;
const stat = recordOutcome(this.store, {
title: test.title,
file: test.location.file,
status: result.status,
failureClass: cls,
});
if (!stat) return;
this.dirty = true;
if (isFrequentFailure(stat, this.store)) {
const rate = stat.runs > 0 ? stat.failed / stat.runs : 0;
this.frequent.set(
stat.id,
`${stat.id} ${stat.failed}/${stat.runs} (${(rate * 100).toFixed(0)}%) ${shortTitle(test.title)}`,
);
}
}
onEnd(_result: FullResult): void {
if (this.dirty) mergeAndSaveFailureStore(this.store);
const file = refreshFrequentFailuresReport();
if (!this.frequent.size) return;
// eslint-disable-next-line no-console
console.log(
`[failure-trend] 经常失败的用例 ${this.frequent.size} 条(榜单 ${path.relative(process.cwd(), file)}):`,
);
for (const item of [...this.frequent.values()].slice(0, 20)) {
// eslint-disable-next-line no-console
console.log(` - ${item}`);
}
}
}
function shortTitle(title: string): string {
return title.length > 80 ? `${title.slice(0, 77)}...` : title;
}
......@@ -5,9 +5,33 @@
| 路径 | 来源 |
|------|------|
| `ci-<buildId>/` | `npm run test:ci` / Jenkins 单次归档 |
| `latest/` | 最近一次 CI 的固定路径(Jenkins HTML / JUnit / Allure) |
| `latest/` | 最近一次 CI 的固定路径(Jenkins HTML / JUnit 阅读入口) |
| `junit/` | 非 runner 直跑时的默认 JUnit |
| `batch-*` / `shard-run-*` / `rerun-*` | 本地历史批跑日志 |
| `flake/` | 失败分类历史、趋势 `latest.md`、自愈建议 |
查看最近一次结果:`reports/latest/summary.json` 与 `reports/latest/playwright-report/index.html`。
## 存储说明(Jenkins 节点爆盘时看这里)
单次 full(`tests/spd`,2 分片)原始产物可达 ~18G,主要来自同一份 trace/截图被保存 4 份:
```
ci-<buildId>/
blob-report/ 4.1G 仅 merge-reports 合并用,合并后可删
shard-1/ shard-2/ 2.7G + 6.1G 分片原始产物(HTML / JUnit / allure / test-results)
playwright-report/ 2.0G + allure-results/ 3.0G 同一批 trace.zip / png
latest/ 5.1G 再复制一份给 Jenkins 报告插件
```
`run-ci.mjs` 现已默认做这些瘦身,无需额外配置:
| 行为 | 开关 | 默认 |
|------|------|------|
| 不生成 Allure(HTML 已含 trace/截图) | `ALLURE=1` 打开 | 关 |
| 合并后删除 `blob-report/`、`shard-N/` | `PW_KEEP_INTERMEDIATE=1` 保留 | 删除 |
| JUnit 只留 `message`,不留整段堆栈 | `PW_JUNIT_COMPACT=0` 保留全文 | 紧凑 |
| 归档内只留最大的 N 个 trace/截图 | `PW_KEEP_TRACES=0` 全删 | 全留 |
| 本机只保留最近 N 份 `ci-*` | `PW_KEEP_ARCHIVES=N` / `--keep-archives=N` | 全留(Jenkinsfile 传 1) |
注意:`PW_KEEP_ARCHIVES` 清理 `run-ci` 产出的 `ci-*`;`batch-*` / `rerun-*` 是手工批跑目录,需自行删除。
# -*- coding: utf-8 -*-
"""为 13 个基础管理页 PO 追加「组合查询」方法(按实测筛选区字段,幂等)。"""
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
# 字段来自 2026-10-20 实测(.ant-form-item label),kind: text / select / dateRange
PAGES = {
"admit-product-list.page.ts": dict(
cls="AdmitProductListPage",
method="filterCombination",
before=" async filterByProductOrFallback()",
fields=[
("制单日期", "dateRange"),
("销售商", "text"),
("耗材类型", "select"),
("器械注册人", "text"),
("注册证号", "text"),
("供应商", "text"),
],
value="样本",
),
"admit-material-list.page.ts": dict(
cls="AdmitMaterialListPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("销售商", "text"),
("耗材类型", "select"),
("器械注册人", "text"),
("注册证号", "text"),
("供应商", "text"),
],
value="样本",
),
"admit-supply.page.ts": dict(
cls="AdmitSupplyPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("销售商", "select"),
("供应商", "select"),
("变更单号", "text"),
("物料名称", "text"),
("规格型号", "text"),
],
value="样本",
),
"admit-task.page.ts": dict(
cls="AdmitTaskPage",
method="filterCombination",
before=None,
fields=[
("新增日期", "dateRange"),
("操作日期", "dateRange"),
("销售商", "text"),
("物料名称", "text"),
("注册证号", "text"),
("操作人", "text"),
("器械注册人", "text"),
("耗材类型", "select"),
("规格型号", "text"),
("准入类型", "select"),
("准入状态", "select"),
],
value="样本",
),
"vendor-qual.page.ts": dict(
cls="VendorQualPage",
method="filterCombination",
before=None,
fields=[
("进院供应商名称", "text"),
("证件类型", "select"),
("授权书名称", "text"),
("授权单位", "text"),
("被授权单位", "text"),
],
value="样本",
),
"vendor-auth-first.page.ts": dict(
cls="VendorAuthFirstPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("进院供应商", "select"),
("预转供应商", "text"),
("状态", "select"),
("变更类型", "select"),
("原供应商", "text"),
("医院ID", "text"),
("医院名称", "text"),
],
value="样本",
),
"vendor-auth-final.page.ts": dict(
cls="VendorAuthFinalPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("进院供应商", "select"),
("预转供应商", "text"),
("状态", "select"),
("变更类型", "select"),
("原供应商", "text"),
],
value="样本",
),
"vendor-cert-first.page.ts": dict(
cls="VendorCertFirstPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("变更类型", "select"),
("注册证号(新)", "text"),
("状态", "select"),
("变更情况具体说明", "text"),
],
value="样本",
),
"vendor-cert-final.page.ts": dict(
cls="VendorCertFinalPage",
method="filterCombination",
before=None,
fields=[
("制单日期", "dateRange"),
("单号", "text"),
("变更类型", "select"),
("注册证号(新)", "text"),
("状态", "select"),
],
value="样本",
),
"vendor-cert-diff.page.ts": dict(
cls="VendorCertDiffPage",
method="filterCombination",
before=" async filterByMaterialSample()",
fields=[
("物料名称", "text"),
("规格型号", "text"),
("状态", "select"),
("望海注册证号", "text"),
("是否医疗器械", "select"),
("生产厂家", "text"),
("FLI+注册证号", "text"),
("27位医保编码", "text"),
],
value="样本",
),
"vendor-eval-scheme.page.ts": dict(
cls="VendorEvalSchemePage",
method="filterCombination",
before=None,
fields=[
("创建日期", "dateRange"),
("启用日期", "dateRange"),
("方案编码", "text"),
("方案名称", "text"),
],
value="样本",
),
"vendor-eval-indicator.page.ts": dict(
cls="VendorEvalIndicatorPage",
method="filterCombination",
before=None,
fields=[
("指标编码", "text"),
("指标名称", "text"),
],
value="样本",
),
"surgery-base-pkg.page.ts": dict(
cls="SurgeryBasePkgPage",
method="filterCombination",
before=None,
fields=[
("产品名称", "text"),
("规格型号", "text"),
("器械注册人", "text"),
("耗材类型", "select"),
("是否收费", "select"),
],
value="样本",
),
"surgery-std-pkg.page.ts": dict(
cls="SurgeryStdPkgPage",
method="filterCombination",
before=None,
fields=[
("产品名称", "text"),
("规格型号", "text"),
("器械注册人", "text"),
("耗材类型", "select"),
("是否收费", "select"),
],
value="样本",
),
}
def method_src(spec: dict) -> str:
rows = "\n".join(
f" {{ label: '{label}', kind: '{kind}', value: '{spec['value']}' }},"
for label, kind in spec["fields"]
)
names = "、".join(lbl for lbl, _ in spec["fields"])
return f""" /** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async {spec['method']}(): Promise<void> {{
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{rows}
],
{{ scope }},
);
expect('{names}'.split('、').length, '组合条件应至少 2 个').toBeGreaterThanOrEqual(2);
}}
"""
def add_import(text: str) -> str:
if "type FilterField" in text or "applyFilterCombination" in text:
return text
# PO 通过基类继承方法,不需要 import FilterField;此处仅确保 expect 可用
if "import { expect" not in text and "import type { Page }" in text:
text = text.replace("import type { Page } from '@playwright/test';", "import { expect, type Page } from '@playwright/test';")
return text
changed = 0
for filename, spec in PAGES.items():
path = ROOT / "pages/spd" / filename
text = path.read_text(encoding="utf-8")
if f"async {spec['method']}()" in text:
print(f"跳过(已存在) {filename}")
continue
text = add_import(text)
snippet = method_src(spec)
if spec["before"] and spec["before"] in text:
text = text.replace(spec["before"], snippet + spec["before"], 1)
else:
# 追加到类末尾
text = text.rstrip()
assert text.endswith("}"), filename
text = text[:-1].rstrip() + "\n\n" + snippet.rstrip() + "\n}\n"
path.write_text(text, encoding="utf-8")
changed += 1
print(f"已写入 {filename}")
print(f"共 {changed} 个 PO")
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