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

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

新模块用例尚未具备通关凭证,本次按临时备份跳过本地门禁。
parent f6eec849
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 产品准入单列表。与物料单列表 URL 分离;通过只取消。 */
export class AdmitProductListPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.productListPath,
pathPart: 'productOrder/list',
menu: admitData.menuProductList,
forbidden: ['zrlist', 'productApply/', 'supplyApply/', 'accessKind/'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
expect(this.page.url()).not.toContain('zrlist');
await expect(this.tab('已通过')).toBeVisible();
}
async expectNotMaterialUrl(): Promise<void> {
expect(this.page.url()).toContain('productOrder/list');
expect(this.page.url()).not.toContain('zrlist');
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'text', value: '样本' },
{ label: '耗材类型', kind: 'select', value: '样本' },
{ label: '器械注册人', kind: 'text', value: '样本' },
{ label: '注册证号', kind: 'text', value: '样本' },
{ label: '供应商', kind: 'text', value: '样本' },
],
{ scope },
);
}
async filterByProductOrFallback(): Promise<void> {
await this.query();
await this.queryByFirstAvailable([
{ column: '产品名称', filter: '产品名称', kind: 'input' },
{ column: '注册证号', filter: '注册证号', kind: 'input' },
{ column: '器械注册人', filter: '器械注册人', kind: 'input' },
]);
}
async resetFilters(): Promise<void> {
await this.query();
const label = (await this.textInput('产品名称').isVisible().catch(() => false)) ? '产品名称' : '注册证号';
await this.fillFilter(label, '样');
await this.reset();
await this.expectInputCleared(label);
await this.expectListOrEmpty();
}
async mockEmpty(): Promise<void> {
if (await this.textInput('产品名称').isVisible().catch(() => false)) {
await this.fillFilter('产品名称', admitData.mockGoods);
} else {
await this.fillFilter('注册证号', admitData.mockCert);
}
await this.query();
await this.expectMockEmpty();
}
async expectCoreColumns(): Promise<void> {
await this.query();
await this.expectHeaders(['准入单号', '进院供应商', '产品名称', '注册证号', '耗材类型', '产品条数', '状态', '备注', '附件', '操作']);
}
async switchPassed(): Promise<void> {
await this.clickTabAndMatchCount('已通过');
await expect(this.tab('已通过')).toHaveClass(/ant-tabs-tab-active/);
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const order = await this.tryCell(row, '准入单号');
await this.openPutFromRow(row, '详情');
if (order) await expect(this.put()).toContainText(order.slice(0, 8));
await this.leaveWithoutCommit();
}
async detailCountMatches(): Promise<void> {
await this.query();
const row = await this.firstRow();
const countText = (await this.cellText(row, '产品条数')).replace(/\s/g, '');
await this.openPutFromRow(row, '详情');
await expect(this.put()).toContainText(countText);
await this.leaveWithoutCommit();
}
async openBillLog(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
await this.openDetailSection('单据日志');
await this.leaveWithoutCommit();
}
async detailSearchMock(): Promise<void> {
await this.query();
await this.openPutFromRow(await this.firstRow(), '详情');
const input = this.putInput('产品名称');
const target = (await input.isVisible().catch(() => false)) ? input : this.put().locator('input').first();
await this.fill(target, admitData.mockGoods, '详情搜索 mock');
const query = this.button('查询', this.put()).first();
if (await query.isVisible().catch(() => false)) await this.click(query, '详情查询');
await this.waitForTableReady();
const empty = await this.put().getByText('暂无数据').first().isVisible().catch(() => false);
const rows = await this.put().locator('.ant-table-tbody > tr.ant-table-row').count();
expect(empty || rows === 0, '详情无匹配应空态').toBeTruthy();
await this.leaveWithoutCommit();
}
async passedNotConfirm(): Promise<void> {
await this.skipIfTabEmpty('已通过');
const row = await this.firstRow();
const order = await this.tryCell(row, '准入单号');
await this.openPutFromRow(row, '详情');
const pass = this.button('通过', this.put()).first();
const visible = await pass.isVisible().catch(() => false);
const disabled = visible && ((await pass.isDisabled().catch(() => false)) || ((await pass.getAttribute('class')) ?? '').includes('disabled'));
if (!visible || disabled) {
await this.leaveWithoutCommit();
} else {
await this.click(pass, '通过');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
if (await confirm.isVisible().catch(() => false)) {
await this.click(confirm.getByRole('button', { name: /取\s*消/ }).first(), '取消通过');
}
await this.leaveWithoutCommit();
}
await this.clickTab('已通过');
if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible();
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { AdmitSharedLocators } from '../../locators/spd/admit-shared.locator';
import { BasePage } from '../../core/base.page';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type AdmitMeta = {
path: string;
/** URL pathname 中用于区分兄弟页的片段,如 productApply/ZRlist */
pathPart: string;
menu: string;
/** 进入后 URL 不应包含的片段 */
forbidden?: string[];
};
const DANGER_CONFIRM = /作废|通过|提交|分配|删除|生成/;
/** 准入管理六页共用:无 iframe、侧栏叶子菜单、表格、Tab、详情层只取消不提交。 */
export class AdmitSharedPage extends BasePage {
readonly locators: AdmitSharedLocators;
constructor(
page: Page,
protected readonly meta: AdmitMeta,
) {
super(page);
this.locators = new AdmitSharedLocators(page);
}
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.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.admitSubmenuTitle.first();
const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first();
const opened = await submenu
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, '展开准入管理');
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.page.waitForURL((url) => url.pathname.includes(this.meta.pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
}
async expectOnPage(): Promise<void> {
expect(this.page.url(), `URL 应含 ${this.meta.pathPart}`).toContain(this.meta.pathPart);
for (const part of this.meta.forbidden ?? []) {
expect(this.page.url(), `不应进入 ${part}`).not.toContain(part);
}
await expect(this.locators.button('查询').first(), '查询按钮').toBeVisible();
expect((await this.headerTexts()).length, '列表表头').toBeGreaterThan(0);
}
button(name: string, root?: Locator): Locator {
return this.locators.button(name, root ?? this.page);
}
async clickButton(name: string, root?: Locator): Promise<void> {
await this.click(this.button(name, root).first(), name);
}
async query(): Promise<void> {
await this.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();
return byRole.or(scope.locator('.ant-form-item').filter({ hasText: label }).locator('input').first());
}
formSelect(label: string, root?: Locator): Locator {
const scope = root ?? this.page.locator('body');
return scope.locator('.ant-form-item').filter({ hasText: label }).locator('.ant-select').first();
}
async fillFilter(label: string, value: string): Promise<void> {
const input = this.textInput(label);
await expect(input, `筛选「${label}」`).toBeVisible();
await this.fill(input, value, `填写${label}`);
}
async chooseSelect(label: string, optionText: string, root?: Locator): Promise<void> {
const select = this.formSelect(label, root);
await this.click(select, `打开${label}`);
const dropdown = this.page.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden)').last();
const keyword = optionText.replace(/\s/g, '').slice(0, 20);
const search = dropdown.locator('input').first();
if (await search.isVisible().catch(() => false)) {
await search.fill(keyword);
await this.waitSettle(200);
}
const option = dropdown.locator('.ant-select-item-option').filter({ hasText: keyword }).first();
await this.click(option, `选择${keyword}`);
await this.waitSettle(200);
}
async selectFirstOption(label: string, root?: Locator): Promise<boolean> {
const select = this.formSelect(label, root);
if (!(await select.isVisible().catch(() => false))) return false;
await this.click(select, `打开${label}`);
const option = this.page
.locator('.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option')
.first();
if (!(await option.isVisible().catch(() => false))) return false;
await this.click(option, `选择${label}首项`);
await this.waitSettle(200);
return true;
}
tab(name: string): Locator {
return this.locators.tab(name);
}
async readTabCount(name: string): Promise<number | null> {
const text = ((await this.tab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const matched = text.match(/\((\d+)\)/) ?? text.match(/(\d+)$/);
return matched ? Number(matched[1]) : null;
}
async clickTab(name: string): Promise<void> {
await this.click(this.tab(name), `切换${name}`);
await this.waitForTableReady();
}
put(): Locator {
return this.locators.putPage();
}
async headerCells(): Promise<Locator> {
const groups = this.page.locator('.ant-table-thead');
const count = await groups.count();
let best = groups.first().locator('th');
let bestLen = 0;
for (let i = 0; i < count; i += 1) {
const cells = groups.nth(i).locator('th');
const len = await cells.count();
if (len > bestLen) {
bestLen = len;
best = cells;
}
}
return best;
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
}
async expectHeaders(names: string[]): Promise<void> {
const text = (await this.headerTexts()).join(' ');
for (const name of names) {
expect(text, `表头应含「${name}」`).toContain(name.replace(/\s/g, ''));
}
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const index = texts.findIndex((item) => item.includes(name.replace(/\s/g, '')));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async listRows(): Promise<Locator> {
const bodies = this.page.locator('.ant-table-tbody');
const count = await bodies.count();
let best = this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
let bestWidth = -1;
for (let i = 0; i < count; i += 1) {
const body = bodies.nth(i);
const insidePut = await body.evaluate((el) => Boolean(el.closest('.put-page'))).catch(() => false);
if (insidePut) continue;
const row = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)').first();
const width = await row.locator('td').count().catch(() => 0);
if (width > bestWidth) {
bestWidth = width;
best = body.locator('> tr.ant-table-row:not(.ant-table-measure-row)');
}
}
return best;
}
async getDataRowCount(): Promise<number> {
return (await this.listRows()).count();
}
async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): 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 expectListOrEmpty(): Promise<void> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
if (rows > 0) {
expect(await this.getTotalCount(), '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1);
return;
}
const emptyVisible = await this.locators.empty.isVisible().catch(() => false);
const emptyText = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(emptyVisible || emptyText || (await this.getTotalCount()) === 0, '无数据时空态应明确').toBeTruthy();
}
async expectMockEmpty(): Promise<void> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(rows === 0 || empty, '无匹配应空态').toBeTruthy();
expect(total === 0 || rows === 0, '分页总数应为 0').toBeTruthy();
}
async expectRowsContain(header: string, keyword: string): Promise<void> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
expect(count, `「${header}」查询应有结果`).toBeGreaterThan(0);
const limit = Math.min(count, 5);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
expect(text, `第 ${i + 1} 行${header}应包含关键字`).toContain(needle);
}
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
/** 制单日期保持默认,只用样本列去点已有筛选项。 */
async queryByFirstAvailable(
pairs: Array<{ column: string; filter: string; kind: 'input' | 'select' }>,
): Promise<void> {
const row = await this.firstRow();
for (const pair of pairs) {
const value = (await this.tryCell(row, pair.column)).trim();
if (!value || value === '--' || value === '-') continue;
if (pair.kind === 'input' && (await this.textInput(pair.filter).isVisible().catch(() => false))) {
await this.fillFilter(pair.filter, value.slice(0, 40));
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
if (pair.kind === 'select' && (await this.formSelect(pair.filter).isVisible().catch(() => false))) {
await this.chooseSelect(pair.filter, value);
await this.query();
await this.expectRowsContain(pair.column, value);
return;
}
}
await this.guardedSkip('筛选区没有与首行样本对应的条件');
}
async collapseKeeps(label: string, value: string): Promise<void> {
const expand = this.button('展开').first();
if (await expand.isVisible().catch(() => false)) {
await this.click(expand, '展开筛选');
}
await this.fillFilter(label, value);
const collapse = this.button('收起').first();
await expect(collapse, '收起按钮').toBeVisible();
await this.click(collapse, '收起');
await this.click(this.button('展开').first(), '再次展开');
await expect(this.textInput(label)).toHaveValue(value);
}
async expectInputCleared(label: string): Promise<void> {
const input = this.textInput(label);
if (await input.isVisible().catch(() => false)) {
await expect(input).toHaveValue('');
}
}
async clickTabAndMatchCount(name: string): Promise<number | null> {
const marked = await this.readTabCount(name);
await this.clickTab(name);
const total = await this.getTotalCount();
if (marked !== null) {
expect(total, `${name} Tab 计数应等于分页总数`).toBe(marked);
}
return marked;
}
async goToPage2(identityHeader: string): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const first = await this.cellText(await this.firstRow(), identityHeader);
const item = this.page.locator('.ant-pagination-item-2').first();
await this.click(item, '翻到第 2 页');
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe('2');
const next = await this.cellText(await this.firstRow(), identityHeader);
expect(next, '第 2 页首行应与第 1 页不同').not.toBe(first);
}
async jumpToPage(pageNum: number): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (n) => this.page.locator(`.ant-pagination-item-${n}`).first().isVisible().catch(() => false),
});
if (!(await this.locators.pageJumpInput.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无跳至输入框或仅 1 页');
}
await this.fill(this.locators.pageJumpInput, String(pageNum), '跳至指定页');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe(String(pageNum));
}
async expectPrevDisabled(): Promise<void> {
await expect(this.locators.prevPage, '第 1 页上一页应不可用').toHaveClass(/ant-pagination-disabled/);
}
rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async rowWithAction(name: string): Promise<Locator | null> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 15);
for (let i = 0; i < count; i += 1) {
const row = rows.nth(i);
if (await this.rowAction(row, name).isVisible().catch(() => false)) return row;
}
return null;
}
async skipIfNoAction(name: string, detail: string): Promise<Locator> {
const row = await this.rowWithAction(name);
if (!row) await this.skipWhenDataInsufficient(detail);
return row as Locator;
}
async skipIfTabEmpty(name: string): Promise<void> {
const marked = await this.readTabCount(name);
await this.clickTab(name);
const rows = await this.getDataRowCount();
if ((marked === 0 || marked === null) && rows === 0) {
await this.skipWhenDataInsufficient(`${name} Tab 无数据`);
}
}
async openPutFromRow(row: Locator, action: string): Promise<void> {
await this.click(this.rowAction(row, action), action);
await expect(this.put(), `${action}应打开详情或编辑层`).toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
await this.assertPageHealthy(action);
}
async leaveWithoutCommit(): Promise<void> {
for (let i = 0; i < 4; i += 1) {
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-popconfirm:visible, .ant-modal:visible').last();
if (await confirm.isVisible().catch(() => false)) {
const text = (await confirm.innerText().catch(() => '')) ?? '';
const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first();
if (DANGER_CONFIRM.test(text)) {
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取消确认');
await this.waitSettle(200);
continue;
}
}
const dismiss = confirm.getByRole('button', { name: /取\s*消|关\s*闭/ }).first();
if (await dismiss.isVisible().catch(() => false)) {
await this.click(dismiss, '关闭弹层');
await this.waitSettle(200);
continue;
}
}
const put = this.page.locator('.put-page:visible').last();
if (!(await put.isVisible().catch(() => false))) return;
const cancel = put.getByRole('button', { name: /取\s*消/ }).first();
const back = put.getByRole('button', { name: /返\s*回/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '取消');
} else if (await back.isVisible().catch(() => false)) {
await this.click(back, '返回');
} else {
return;
}
await this.waitSettle(300);
}
await expect(this.page.locator('.put-page:visible'), '应离开详情或编辑层').toHaveCount(0);
}
async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|请先/).first();
await expect(this.locators.formError.first().or(hint), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 });
const stayed =
(await this.page.locator('.put-page:visible').count()) > 0 ||
(await this.page.locator('.ant-modal:visible').count()) > 0;
expect(stayed, '拦截后页面不应关闭').toBeTruthy();
}
async clearChecks(): Promise<void> {
for (let i = 0; i < 8; i += 1) {
const checked = this.page.locator('.ant-table-tbody .ant-checkbox-checked').first();
if (!(await checked.isVisible().catch(() => false))) return;
await checked.click();
await this.waitSettle(100);
}
}
async checkFirstRow(): Promise<Locator> {
const row = await this.firstRow();
const box = row.locator('.ant-checkbox').first();
await this.click(box, '勾选首行');
return row;
}
/** 未勾选:按钮禁用,或点击后只出现提示 / 可取消的确认,不点确定。 */
async expectNeedsSelection(name: string): Promise<void> {
await this.clearChecks();
const total = await this.getTotalCount();
const button = this.button(name).first();
await expect(button, name).toBeVisible();
if (await button.isDisabled().catch(() => false)) return;
const className = (await button.getAttribute('class')) ?? '';
if (className.includes('ant-btn-disabled')) return;
await this.click(button, name);
const hint = this.page.getByText(/请选择|请勾选|至少选择|未选择/).first();
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible').last();
const hinted = await hint.isVisible().catch(() => false);
const confirmed = await confirm.isVisible().catch(() => false);
expect(hinted || confirmed, `${name}未勾选时应不可用或提示`).toBeTruthy();
if (confirmed) {
const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) await this.click(cancel, '取消');
}
expect(await this.getTotalCount(), '未勾选不应新增单据').toBe(total);
}
async clickToolbarThenCancel(name: string): Promise<void> {
await this.clickButton(name);
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer, `${name}应打开操作层`).toBeVisible({ timeout: 10_000 });
await this.leaveWithoutCommit();
}
async openDetailSection(name: string): Promise<void> {
const target = this.put().getByRole('button', { name: new RegExp(name) }).or(this.put().getByText(name)).first();
await this.click(target, name);
await this.waitSettle(300);
await this.assertPageHealthy(name);
await expect(this.put(), '详情层仍在').toBeVisible();
}
putInput(label: string): Locator {
return this.textInput(label, this.put());
}
async saveOnPut(): Promise<void> {
const save = this.button('保存', this.put()).first();
if (await save.isVisible().catch(() => false)) {
await this.click(save, '保存');
return;
}
await this.click(this.button('提交', this.put()).first(), '提交');
}
}
import { expect, type Page } from '@playwright/test';
import { admitData, uniqRemark } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 供货目录申请。提交与批量提交只取消,不确认。 */
export class AdmitSupplyPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.supplyPath,
pathPart: 'supplyApply/list',
menu: admitData.menuSupply,
forbidden: ['productApply/', 'productOrder/', 'accessKind/'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.button('新增').first()).toBeVisible();
await expect(this.button('批量提交').first()).toBeVisible();
}
async filterBySupplier(): Promise<void> {
await this.query();
const supplier = await this.sampleCell('进院供应商');
await this.chooseSelect('进院供应商', supplier);
await this.query();
await this.expectRowsContain('进院供应商', supplier);
}
async resetFilters(): Promise<void> {
await this.query();
const supplier = await this.sampleCell('进院供应商');
await this.chooseSelect('进院供应商', supplier);
await this.reset();
await this.expectListOrEmpty();
}
async mockEmpty(): Promise<void> {
if (await this.textInput('变更单号').isVisible().catch(() => false)) {
await this.fillFilter('变更单号', admitData.mockOrder);
} else {
await this.fillFilter('产品名称', admitData.mockGoods);
}
await this.query();
await this.expectMockEmpty();
}
async expectCoreColumns(): Promise<void> {
await this.query();
await this.expectHeaders(['申请单号', '申请人', '申请时间', '进院供应商', '二级供应商', '业务模式', '是否默认', '备注', '操作']);
}
async switchPending(): Promise<void> {
await this.clickTabAndMatchCount('待提交');
await expect(this.tab('待提交')).toHaveClass(/ant-tabs-tab-active/);
}
async openAddFields(): Promise<void> {
await this.clickButton('新增');
await expect(this.put()).toBeVisible();
for (const label of ['进院供应商', '二级供应商', '业务模式', '是否默认', '备注']) {
await expect(this.put().getByText(label).first(), label).toBeVisible();
}
await this.leaveWithoutCommit();
}
async addCancel(): Promise<void> {
const total = await this.getTotalCount();
const remark = uniqRemark();
await this.clickButton('新增');
await this.fill(this.putInput('备注'), remark, '填写备注');
await this.leaveWithoutCommit();
expect(await this.getTotalCount(), '未提交总数不变').toBe(total);
await expect(this.page.getByText(remark)).toHaveCount(0);
}
async switchModeWithoutSubmit(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.selectFirstOption('业务模式', this.put());
const no = this.put().getByText('否', { exact: true }).first();
if (await no.isVisible().catch(() => false)) await this.click(no, '切换是否默认');
else await this.selectFirstOption('是否默认', this.put());
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async editBackfill(): Promise<void> {
await this.clickTab('待提交');
const row = await this.skipIfNoAction('编辑', '待提交无编辑');
const supplier = await this.cellText(row, '进院供应商');
await this.openPutFromRow(row, '编辑');
await expect(this.put()).toContainText(supplier.slice(0, 6));
await this.leaveWithoutCommit();
}
async editCancel(): Promise<void> {
await this.clickTab('待提交');
const row = await this.skipIfNoAction('编辑', '待提交无编辑');
await this.openPutFromRow(row, '编辑');
const input = this.putInput('备注');
const original = await input.inputValue().catch(() => '');
await this.fill(input, uniqRemark(), '临时备注');
await this.leaveWithoutCommit();
await this.clickTab('待提交');
const again = await this.skipIfNoAction('编辑', '待提交无编辑');
await this.openPutFromRow(again, '编辑');
await expect(this.putInput('备注')).toHaveValue(original);
await this.leaveWithoutCommit();
}
async emptySupplierBlocked(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.clickButton('提交', this.put());
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async noProductBlocked(): Promise<void> {
const total = await this.getTotalCount();
await this.clickButton('新增');
await this.selectFirstOption('进院供应商', this.put());
await this.selectFirstOption('二级供应商', this.put());
await this.selectFirstOption('业务模式', this.put());
await this.clickButton('提交', this.put());
await this.expectBlockedAndStay();
await this.leaveWithoutCommit();
expect(await this.getTotalCount()).toBe(total);
}
async batchNeedsSelection(): Promise<void> {
await this.expectNeedsSelection('批量提交');
}
async batchCancel(): Promise<void> {
await this.skipIfTabEmpty('待提交');
const row = await this.checkFirstRow();
const order = await this.tryCell(row, '申请单号');
await this.clickButton('批量提交');
const confirm = this.page.locator('.ant-modal-confirm:visible, .ant-modal:visible, .put-page:visible').last();
await expect(confirm).toBeVisible();
const cancel = confirm.getByRole('button', { name: /取\s*消/ }).first();
await this.click(cancel, '取消批量提交');
await this.clickTab('待提交');
if (order) await expect(this.page.getByText(order.slice(0, 10)).first()).toBeVisible();
}
async detailAndBack(): Promise<void> {
await this.query();
const row = await this.firstRow();
const order = await this.tryCell(row, '申请单号');
await this.openPutFromRow(row, '详情');
if (order) await expect(this.put()).toContainText(order.slice(0, 8));
await expect(this.put().getByText('审批进程').first()).toBeVisible();
await this.leaveWithoutCommit();
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '制单日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'select', value: '样本' },
{ label: '供应商', kind: 'select', value: '样本' },
{ label: '变更单号', kind: 'text', value: '样本' },
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { admitData } from '../../data/admit.data';
import { AdmitSharedPage } from './admit-shared.page';
/** 准入任务分配。分配只取消,不确认。 */
export class AdmitTaskPage extends AdmitSharedPage {
constructor(page: Page) {
super(page, {
path: admitData.taskPath,
pathPart: 'accessKind/list',
menu: admitData.menuTask,
forbidden: ['productApply/', 'productOrder/', 'supplyApply/'],
});
}
async expectEntered(): Promise<void> {
await this.expectOnPage();
await expect(this.tab('待分类')).toBeVisible();
await expect(this.tab('已分类')).toBeVisible();
await expect(this.button('批量分配').first()).toBeVisible();
}
async filterByProductName(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 40));
await this.query();
await this.expectRowsContain('产品名称', name);
}
async switchTabs(): Promise<void> {
await this.clickTabAndMatchCount('待分类');
await this.clickTabAndMatchCount('已分类');
await this.clickTab('待分类');
await expect(this.tab('待分类')).toHaveClass(/ant-tabs-tab-active/);
}
async resetFilters(): Promise<void> {
await this.query();
const name = await this.sampleCell('产品名称');
await this.fillFilter('产品名称', name.slice(0, 20));
await this.reset();
await this.expectInputCleared('产品名称');
await this.expectListOrEmpty();
}
async mockEmpty(): Promise<void> {
await this.fillFilter('产品名称', admitData.mockGoods);
await this.query();
await this.expectMockEmpty();
}
async expectCoreColumns(): Promise<void> {
await this.clickTab('待分类');
await this.expectHeaders(['准入类型', '产品编号', '产品名称', '规格型号', '规格', '型号', '器械注册人', '操作']);
if ((await this.getDataRowCount()) > 0) {
await expect(this.rowAction(await this.firstRow(), '分配类型')).toBeVisible();
}
}
async goPage2(): Promise<void> {
await this.query();
await this.goToPage2('产品名称');
}
async jumpPage2(): Promise<void> {
await this.query();
await this.jumpToPage(2);
}
async prevDisabled(): Promise<void> {
await this.query();
await this.expectPrevDisabled();
}
async assignCancel(): Promise<void> {
await this.clickTab('待分类');
const before = await this.readTabCount('待分类');
const row = await this.skipIfNoAction('分配类型', '待分类无分配类型');
const name = await this.cellText(row, '产品名称');
await this.click(this.rowAction(row, '分配类型'), '分配类型');
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer, '分配层应打开').toBeVisible();
await this.leaveWithoutCommit();
await this.clickTab('待分类');
await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible();
if (before !== null) expect(await this.readTabCount('待分类')).toBe(before);
}
async batchNeedsSelection(): Promise<void> {
await this.clickTab('待分类');
await this.expectNeedsSelection('批量分配');
}
async batchCancel(): Promise<void> {
await this.clickTab('待分类');
if ((await this.getDataRowCount()) === 0) await this.skipWhenDataInsufficient('待分类无数据');
const before = await this.readTabCount('待分类');
const row = await this.checkFirstRow();
const name = await this.cellText(row, '产品名称');
await this.clickButton('批量分配');
const layer = this.page.locator('.put-page:visible, .ant-modal:visible, .ant-drawer:visible').last();
await expect(layer).toBeVisible();
await this.leaveWithoutCommit();
await this.clickTab('待分类');
await expect(this.page.getByText(name.slice(0, 12)).first()).toBeVisible();
if (before !== null) expect(await this.readTabCount('待分类')).toBe(before);
}
async classifiedNoAssign(): Promise<void> {
await this.skipIfTabEmpty('已分类');
const assign = (await this.listRows()).getByRole('button', { name: /分配类型/ });
if ((await assign.count()) === 0) return;
const before = await this.readTabCount('已分类');
await this.click(assign.first(), '已分类分配类型');
const hint = this.page.getByText(/不可|不允许|不能|已分类/).first();
const layer = this.page.locator('.ant-modal:visible, .put-page:visible').last();
expect(
(await hint.isVisible().catch(() => false)) || (await layer.isVisible().catch(() => false)),
'已分类再分配应被拒绝或需取消',
).toBeTruthy();
await this.leaveWithoutCommit();
if (before !== null) expect(await this.readTabCount('已分类')).toBe(before);
}
/** 组合查询:一次性填满筛选区全部字段后查询(字段以现网搜索条为准)。 */
async filterCombination(): Promise<void> {
const scope = await this.queryScope();
await this.applyFilterCombination(
[
{ label: '新增日期', kind: 'dateRange', value: '样本' },
{ label: '操作日期', kind: 'dateRange', value: '样本' },
{ label: '销售商', kind: 'text', value: '样本' },
{ label: '物料名称', kind: 'text', value: '样本' },
{ label: '注册证号', kind: 'text', value: '样本' },
{ label: '操作人', kind: 'text', value: '样本' },
{ label: '器械注册人', kind: 'text', value: '样本' },
{ label: '耗材类型', kind: 'select', value: '样本' },
{ label: '规格型号', kind: 'text', value: '样本' },
{ label: '准入类型', kind: 'select', value: '样本' },
{ label: '准入状态', kind: 'select', value: '样本' },
],
{ scope },
);
}
}
import { expect, type Page } from '@playwright/test';
import { basicHospitalData, uniqCampusName } from '../../data/basic-hospital.data';
import { BasicBranchLocators } from '../../locators/spd/basic-branch.locator';
import { BasicHospitalSharedPage } from './basic-shared.page';
export class BasicBranchPage extends BasicHospitalSharedPage {
readonly locators: BasicBranchLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicBranchLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.branchPath);
}
async resetSessionForReuse(): Promise<void> {
await this.closeDialogIfOpen();
if (await this.locators.campusNameInput.isVisible().catch(() => false)) {
await this.locators.campusNameInput.fill('');
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.waitForTableReady();
}
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/paltform/area');
await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/info', '/balance/month']);
await expect(this.locators.addBtn).toBeVisible();
await expect(this.locators.campusNameInput).toBeVisible();
await expect(this.shared.tableHeader.first()).toBeVisible();
await expect(this.page.getByRole('button', { name: /查\s*询/ })).toHaveCount(0);
await expect(this.page.getByRole('button', { name: /重\s*置/ })).toHaveCount(0);
}
async extractSample(): Promise<{ sampleCampusName: string; sampleCampusId: string; sampleCampusAddress: string }> {
const row = await this.firstRow();
return {
sampleCampusName: await this.cellText(row, '院区名称'),
sampleCampusId: await this.cellText(row, '院区编号'),
sampleCampusAddress: await this.cellText(row, '院区地址'),
};
}
async searchByName(name: string): Promise<void> {
await this.fill(this.locators.campusNameInput, name, '填写院区名称');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
async expectNameResults(keyword: string): Promise<void> {
const count = await this.getDataRowCount();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '院区名称');
expect(text).toContain(keyword.slice(0, Math.min(keyword.length, 6)));
}
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['序号', '院区编号', '院区名称', '院区地址', '状态', '启用云仓', '操作']);
await expect((await this.firstRow()).getByRole('button', { name: /编\s*辑/ })).toBeVisible();
}
async clearSearch(): Promise<void> {
const filtered = await this.getTotalCount();
await this.locators.campusNameInput.fill('');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const restored = await this.getTotalCount();
expect(restored, '清空后条数应不少于过滤后').toBeGreaterThanOrEqual(filtered);
}
async expectMockEmpty(): Promise<void> {
const rows = await this.getDataRowCount();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(rows === 0 || empty).toBeTruthy();
expect(await this.getTotalCount()).toBe(0);
}
async openAddDialog(): Promise<void> {
await this.click(this.locators.addBtn, '新 增');
await expect(this.shared.dialog).toContainText('新增院区');
await expect(this.locators.addName).toBeVisible();
await expect(this.dialogFormItem('所在地区')).toBeVisible();
await expect(this.locators.addAddress).toBeVisible();
await expect(this.shared.dialog.getByText('主院区')).toBeVisible();
await expect(this.shared.dialog.getByText('停用')).toBeVisible();
await expect(this.shared.dialog.getByText('启用').first()).toBeVisible();
await expect(this.locators.editId).toHaveCount(0);
await expect(this.shared.dialog.getByText('启用云仓')).toHaveCount(0);
}
async addAndCancel(): Promise<void> {
const total = await this.getTotalCount();
const name = uniqCampusName();
await this.openAddDialog();
await this.fill(this.locators.addName, name, '填写院区名称');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await expect(this.shared.dialog).toBeHidden({ timeout: 8_000 });
expect(await this.getTotalCount()).toBe(total);
await this.searchByName(name);
await this.expectMockEmpty();
await this.locators.campusNameInput.fill('');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
}
async addSuccessAndCleanup(): Promise<void> {
const name = uniqCampusName();
await this.openAddDialog();
await this.fill(this.locators.addName, name, '填写院区名称');
await this.pickCascaderFirstPath();
await this.fill(this.locators.addAddress, '自动化测试地址', '填写详细地址');
await this.dialogFormItem('主院区').getByText('否', { exact: true }).click();
await this.dialogFormItem('状态').getByText('启用', { exact: true }).click();
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await expect(this.shared.dialog).toBeHidden({ timeout: 15_000 });
await this.searchByName(name);
expect(await this.cellText(await this.firstRow(), '院区名称')).toContain(name);
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toBeVisible();
const disable = this.dialogFormItem('状态').getByText(/禁用|停用/).first();
if (await disable.isVisible().catch(() => false)) {
await disable.click();
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '停用测试院区');
await this.waitSpinGone();
} else {
await this.closeDialogIfOpen();
}
}
async addEmptyName(): Promise<void> {
await this.openAddDialog();
await this.pickCascaderFirstPath();
await this.fill(this.locators.addAddress, basicHospitalData.mockAddr, '填写地址');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async addEmptyRegion(): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.addName, basicHospitalData.mockCampusNoRegion, '填写名称');
await this.fill(this.locators.addAddress, basicHospitalData.mockAddr, '填写地址');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async addEmptyAddress(): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.addName, basicHospitalData.mockCampusNoAddr, '填写名称');
await this.pickCascaderFirstPath();
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async openEditAndExpectBackfill(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toContainText('编辑院区');
expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId);
expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName);
expect(await this.locators.editAddress.inputValue()).toContain(sample.sampleCampusAddress.slice(0, 4));
await expect(this.shared.dialog.getByText('启用云仓')).toBeVisible();
await this.closeDialogIfOpen();
}
async expectIdNotChangeable(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.locators.editId).toBeVisible();
expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId);
const disabled = await this.locators.editId.isDisabled().catch(() => false);
const readonly = await this.locators.editId.getAttribute('readonly');
if (!disabled && readonly === null) {
await this.fill(this.locators.editId, basicHospitalData.mockCampusId, '尝试改编号');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.waitSpinGone();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.editId.inputValue()).toBe(sample.sampleCampusId);
}
await this.closeDialogIfOpen();
}
async editAndCancel(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await this.fill(this.locators.editName, uniqCampusName(), '改名称');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName);
await this.closeDialogIfOpen();
}
async expectEditStatusLabels(): Promise<void> {
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog.getByText('禁用')).toBeVisible();
await expect(this.shared.dialog.getByText('启用').first()).toBeVisible();
await expect(this.dialogFormItem('状态').getByText('停用')).toHaveCount(0);
await this.closeDialogIfOpen();
}
async expectCloudWarehouse(): Promise<void> {
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.dialogFormItem('启用云仓').getByText('是', { exact: true })).toBeVisible();
await expect(this.dialogFormItem('启用云仓').getByText('否', { exact: true })).toBeVisible();
await this.closeDialogIfOpen();
}
async editClearRequired(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await this.locators.editName.fill('');
await this.click(this.shared.dialog.getByRole('button', { name: /确\s*定/ }), '确 定');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.editName.inputValue()).toBe(sample.sampleCampusName);
await this.closeDialogIfOpen();
}
}
export const BASIC_BRANCH_PATH = basicHospitalData.branchPath;
import { expect, type Locator, type Page, test } from '@playwright/test';
import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicFinanceMonthLocators } from '../../locators/spd/basic-finance-month.locator';
import { BasicHospitalSharedPage } from './basic-shared.page';
export type FinanceSample = {
sampleFinanceMonth: string;
sampleStartDate: string;
sampleEndDate: string;
sampleAutoSettleDate: string;
};
export class BasicFinanceMonthPage extends BasicHospitalSharedPage {
readonly locators: BasicFinanceMonthLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicFinanceMonthLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.financeMonthPath);
}
async search(): Promise<void> {
await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady();
}
async resetSessionForReuse(options?: { search?: boolean }): Promise<void> {
if (await this.locators.cancelBtn.isVisible().catch(() => false)) {
await this.click(this.locators.cancelBtn, '取消行内编辑');
}
if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
if (options?.search) await this.search();
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/balance/month');
await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/info', '/paltform/area']);
await expect(this.locators.settleMonth).toBeVisible();
await expect(this.locators.searchBtn).toBeVisible();
await expect(this.locators.resetBtn).toBeVisible();
await expect(this.shared.tableHeader.first()).toBeVisible();
}
currentMonthRow(): Locator {
return this.dataRows().filter({ hasText: '当前月份' }).first();
}
historyRows(): Locator {
return this.dataRows().filter({ hasText: '历史月份' });
}
async extractSample(): Promise<FinanceSample> {
await this.search();
const row = (await this.currentMonthRow().isVisible().catch(() => false))
? this.currentMonthRow()
: await this.firstRow();
return {
sampleFinanceMonth: await this.cellText(row, '财务月份'),
sampleStartDate: await this.cellText(row, '开始日期'),
sampleEndDate: await this.cellText(row, '结束日期'),
sampleAutoSettleDate: await this.cellText(row, '自动结算日期'),
};
}
async queryByMonth(month: string): Promise<void> {
await this.click(this.locators.settleMonth, '打开财务月份');
const panel = this.page.locator('.ant-picker-dropdown:visible').last();
const opt = panel.getByText(month, { exact: true }).first();
if (await opt.isVisible().catch(() => false)) {
await this.click(opt, `选择 ${month}`);
} else {
await this.locators.settleMonth.fill(month);
await this.page.keyboard.press('Enter');
}
await this.search();
}
async expectMonthResults(month: string): Promise<void> {
const count = await this.getDataRowCount();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '财务月份');
expect(text).toContain(month);
}
}
async resetFilters(): Promise<void> {
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['财务月份', '开始日期', '结束日期', '自动结算日期', '状态', '操作']);
}
async expectOnlyCurrentEditable(): Promise<void> {
const current = this.currentMonthRow();
await expect(current, '应存在当前月份行').toBeVisible();
await expect(current.getByRole('button', { name: /修\s*改/ })).toBeVisible();
const history = this.historyRows();
const historyCount = await history.count();
for (let i = 0; i < historyCount; i += 1) {
await expect(history.nth(i).getByRole('button', { name: /修\s*改/ })).toHaveCount(0);
}
}
async expectAtMostOneCurrent(): Promise<void> {
const count = await this.dataRows().filter({ hasText: '当前月份' }).count();
expect(count, '当前月份至多一行').toBeLessThanOrEqual(1);
}
async expectHistoryNotEditable(): Promise<void> {
const history = this.historyRows();
if ((await history.count()) === 0) {
test.skip(true, '无历史月份');
}
await history.first().click({ trial: true }).catch(() => undefined);
await expect(this.locators.saveBtn).toBeHidden();
await expect(this.locators.autoSettleDateInput).toBeHidden();
}
async enterInlineEdit(): Promise<void> {
const row = this.currentMonthRow();
await expect(row).toBeVisible();
await this.click(row.getByRole('button', { name: /修\s*改/ }), '修 改');
await expect(this.locators.saveBtn).toBeVisible();
await expect(this.locators.cancelBtn).toBeVisible();
await expect(this.locators.autoSettleDateInput).toBeVisible();
}
async expectStartEndReadonly(): Promise<void> {
await this.enterInlineEdit();
const row = this.currentMonthRow();
const startInput = row.locator('td').nth(await this.headerIndex('开始日期')).locator('input');
const endInput = row.locator('td').nth(await this.headerIndex('结束日期')).locator('input');
if (await startInput.count()) await expect(startInput.first()).toBeDisabled();
if (await endInput.count()) await expect(endInput.first()).toBeDisabled();
await expect(this.locators.autoSettleDateInput).toBeEditable();
await this.click(this.locators.cancelBtn, '取 消');
}
async pickAltDateInRange(start: string, end: string, current: string): Promise<string> {
const startDay = Number(start.slice(-2));
const endDay = Number(end.slice(-2));
const curDay = Number(current.slice(-2));
let next = curDay === startDay ? Math.min(endDay, startDay + 1) : startDay;
if (next === curDay) next = Math.min(endDay, curDay + 1);
return `${start.slice(0, 8)}${String(next).padStart(2, '0')}`;
}
async fillAutoSettle(date: string): Promise<void> {
await this.locators.autoSettleDateInput.click();
await this.locators.autoSettleDateInput.fill(date);
await this.page.keyboard.press('Enter');
await this.waitSettle(200);
}
async cancelWithoutSave(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
const alt = await this.pickAltDateInRange(sample.sampleStartDate, sample.sampleEndDate, sample.sampleAutoSettleDate);
await this.fillAutoSettle(alt);
await this.click(this.locators.cancelBtn, '取 消');
const shown = await this.cellText(this.currentMonthRow(), '自动结算日期');
expect(shown).toContain(sample.sampleAutoSettleDate);
}
async saveAndRestore(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
const alt = await this.pickAltDateInRange(sample.sampleStartDate, sample.sampleEndDate, sample.sampleAutoSettleDate);
await this.fillAutoSettle(alt);
await this.click(this.locators.saveBtn, '保 存');
await this.waitSpinGone();
await this.search();
expect(await this.cellText(this.currentMonthRow(), '自动结算日期')).toContain(alt);
await this.enterInlineEdit();
await this.fillAutoSettle(sample.sampleAutoSettleDate);
await this.click(this.locators.saveBtn, '恢复自动结算日');
await this.waitSpinGone();
}
async saveEmptyDate(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
await this.locators.autoSettleDateInput.fill('');
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.click(this.locators.saveBtn, '保 存');
await this.expectFormError();
const shown = await this.cellText(this.currentMonthRow(), '自动结算日期');
expect(shown.includes(sample.sampleAutoSettleDate) || shown.length > 0).toBeTruthy();
if (await this.locators.cancelBtn.isVisible().catch(() => false)) {
await this.click(this.locators.cancelBtn, '取 消');
}
}
async saveOutOfRange(): Promise<void> {
const sample = await this.extractSample();
await this.enterInlineEdit();
try {
await this.fillAutoSettle(basicHospitalData.mockOutOfRangeDate);
await this.click(this.locators.saveBtn, '保 存');
await this.expectFormError();
const shown = await this.cellText(this.currentMonthRow(), '自动结算日期');
expect(shown).not.toContain('1999');
} catch {
test.skip(true, '日期控件无法选出区间外日期');
} finally {
if (await this.locators.cancelBtn.isVisible().catch(() => false)) {
await this.click(this.locators.cancelBtn, '取 消');
}
}
expect(await this.cellText(this.currentMonthRow(), '自动结算日期')).toContain(sample.sampleAutoSettleDate);
}
}
export const BASIC_FINANCE_MONTH_PATH = basicHospitalData.financeMonthPath;
import { expect, type Page, test } from '@playwright/test';
import { basicHospitalData, uniqLinkman } from '../../data/basic-hospital.data';
import { BasicHospitalInfoLocators } from '../../locators/spd/basic-hospital-info.locator';
import { BasicHospitalSharedPage } from './basic-shared.page';
export class BasicHospitalInfoPage extends BasicHospitalSharedPage {
readonly locators: BasicHospitalInfoLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicHospitalInfoLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.hospitalInfoPath);
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/paltform/info');
await this.expectNotOnSiblingPages(['/paltform/notice', '/paltform/area', '/balance/month']);
await expect(this.locators.editBtn, '查看态编辑').toBeVisible();
await expect(this.page.getByText('医院编码').first()).toBeVisible();
await expect(this.page.getByText('医院名称').first()).toBeVisible();
await expect(this.page.getByText(/地址/).first()).toBeVisible();
}
async expectViewReadonly(): Promise<void> {
await expect(this.locators.saveBtn).toBeHidden();
await expect(this.locators.cancelBtn).toBeHidden();
await this.expectSystemFieldsReadonly();
}
async expectSystemFieldsReadonly(): Promise<void> {
for (const label of ['医院编码', '医院名称']) {
const input = this.formItem(label).locator('input:not([type="hidden"])');
if (await input.count()) {
await expect(input.first(), `${label} 应只读`).toBeDisabled();
}
}
}
async previewIfPresent(): Promise<void> {
const preview = this.locators.previewLink.first();
if (!(await preview.isVisible().catch(() => false))) {
test.skip(true, '均无预览入口');
}
await this.click(preview, '预览文件');
const previewLayer = this.page.locator('.ant-image-preview, .ant-modal, .ant-image').first();
await expect(previewLayer.or(this.page.locator('img').first()), '预览应打开').toBeVisible({ timeout: 8_000 });
await this.page.keyboard.press('Escape').catch(() => undefined);
}
async enterEdit(): Promise<void> {
await this.click(this.locators.editBtn, '编 辑');
await expect(this.locators.saveBtn).toBeVisible();
await expect(this.locators.cancelBtn).toBeVisible();
await expect(this.locators.linkman).toBeEditable();
await expect(this.locators.contactWay).toBeEditable();
await expect(this.locators.shortName).toBeEditable();
}
async readLinkman(): Promise<string> {
if (await this.locators.linkman.isVisible().catch(() => false)) {
return this.locators.linkman.inputValue();
}
return ((await this.formItem('联系人').innerText()) ?? '').replace(/联系人\*?/, '').trim();
}
async cancelWithoutSave(): Promise<void> {
await this.enterEdit();
const original = await this.readLinkman();
await this.fill(this.locators.linkman, uniqLinkman(), '改联系人');
await this.click(this.locators.cancelBtn, '取 消');
await expect(this.locators.saveBtn).toBeHidden();
const shown = await this.readLinkman();
expect(shown).toContain(original);
}
async saveAndRestore(): Promise<void> {
await this.enterEdit();
const original = await this.readLinkman();
const next = uniqLinkman();
await this.fill(this.locators.linkman, next, '改联系人');
await this.click(this.locators.saveBtn, '保 存');
await this.waitSpinGone();
await this.open();
await this.enterEdit();
expect(await this.readLinkman()).toBe(next);
await this.fill(this.locators.linkman, original, '恢复联系人');
await this.click(this.locators.saveBtn, '保 存恢复');
await this.waitSpinGone();
}
async saveEmptyField(field: 'linkman' | 'contactWay' | 'shortName'): Promise<void> {
await this.enterEdit();
const locator = this.locators[field];
await locator.fill('');
await this.click(this.locators.saveBtn, '保 存');
await this.expectFormError();
await expect(this.locators.saveBtn, '仍停留编辑态').toBeVisible();
await this.click(this.locators.cancelBtn, '取 消');
}
async expectUploadInputs(): Promise<void> {
await this.enterEdit();
await expect(this.locators.loginBgUpload).toHaveCount(1);
await expect(this.page.locator('input[type="file"]')).toHaveCount(2);
await this.click(this.locators.cancelBtn, '取 消');
}
}
export const BASIC_HOSPITAL_INFO_PATH = basicHospitalData.hospitalInfoPath;
import { expect, type Page } from '@playwright/test';
import {
basicHospitalData,
uniqNoticeTitle,
} from '../../data/basic-hospital.data';
import { BasicNoticeLocators } from '../../locators/spd/basic-notice.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
import { BasicHospitalSharedPage } from './basic-shared.page';
export class BasicNoticePage extends BasicHospitalSharedPage {
readonly locators: BasicNoticeLocators;
constructor(page: Page) {
super(page);
this.locators = new BasicNoticeLocators(page);
}
async open(): Promise<void> {
await this.openPath(basicHospitalData.noticePath);
}
async search(): Promise<void> {
await this.click(this.locators.searchBtn, '查 询');
await this.waitForTableReady();
}
async resetSessionForReuse(options?: { search?: boolean }): Promise<void> {
await this.closeDialogIfOpen();
if (await this.locators.resetBtn.isVisible().catch(() => false)) {
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
}
if (options?.search) await this.search();
}
async expectEntered(): Promise<void> {
await this.expectUrlContains('/spd/basic/hospital/paltform/notice');
await this.expectNotOnSiblingPages(['/paltform/info', '/paltform/area', '/balance/month']);
await expect(this.locators.noticeTitle, '筛选区公告标题').toBeVisible();
await expect(this.locators.addBtn, '新增按钮').toBeVisible();
await expect(this.shared.tableHeader.first(), '列表表头').toBeVisible();
}
async extractSample(): Promise<{ sampleNoticeTitle: string; samplePublishScope: string }> {
await this.search();
const row = await this.firstRow();
return {
sampleNoticeTitle: await this.cellText(row, '公告标题'),
samplePublishScope: await this.cellText(row, '发布范围'),
};
}
async queryByTitle(title: string): Promise<void> {
await this.fill(this.locators.noticeTitle, title, '填写公告标题');
await this.search();
}
async expectTitleResultsMatch(keyword: string): Promise<void> {
const count = await this.getDataRowCount();
expect(count, '标题查询应有结果').toBeGreaterThan(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '公告标题');
expect(text, `第 ${i + 1} 行标题应包含关键字`).toContain(keyword.slice(0, Math.min(keyword.length, 8)));
}
}
async queryByScope(scope: string): Promise<void> {
await this.selectDropdown(this.locators.publishScopeSelect, scope);
await this.search();
}
async expectScopeResultsMatch(scope: string): Promise<void> {
if (scope === '全部') return;
const count = await this.getDataRowCount();
expect(count, '范围查询应有结果或空态可接受').toBeGreaterThanOrEqual(0);
for (let i = 0; i < Math.min(count, 5); i += 1) {
const text = await this.cellText(this.dataRows().nth(i), '发布范围');
expect(text, `第 ${i + 1} 行发布范围`).toContain(scope);
}
}
async queryByTitleAndScope(title: string, scope: string): Promise<void> {
await this.fill(this.locators.noticeTitle, title, '填写公告标题');
if (scope && scope !== '全部') {
await this.selectDropdown(this.locators.publishScopeSelect, scope);
}
await this.search();
}
async resetFilters(): Promise<void> {
await this.click(this.locators.resetBtn, '重 置');
await this.waitForTableReady();
await expect(this.locators.noticeTitle).toHaveValue('');
}
async expectMockEmpty(): Promise<void> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const empty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(rows === 0 || empty, '无匹配应空态').toBeTruthy();
expect(total === 0 || rows === 0, '分页总数应为 0').toBeTruthy();
}
async expectCoreColumns(): Promise<void> {
await this.expectHeaders(['公告编号', '公告标题', '发布范围', '发布科室', '发布人', '操作']);
const row = await this.firstRow();
await expect(row.getByRole('button', { name: /编\s*辑/ })).toBeVisible();
await expect(row.getByRole('button', { name: /删\s*除/ })).toBeVisible();
}
async goToPage2(): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: (n) => this.hasPaginationItem(n),
});
const firstNo = await this.cellText(await this.firstRow(), '公告编号');
await this.clickPaginationPage(2);
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe('2');
const nextNo = await this.cellText(await this.firstRow(), '公告编号');
expect(nextNo, '第 2 页首行应与第 1 页不同').not.toBe(firstNo);
}
async jumpToPage(pageNum: number): Promise<void> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: (n) => this.hasPaginationItem(n),
});
if (!(await this.locators.pageJumpInput.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无 jumper 或仅 1 页');
}
await this.fill(this.locators.pageJumpInput, String(pageNum), '跳至指定页');
await this.page.keyboard.press('Enter');
await this.waitForTableReady();
const active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
expect(active).toBe(String(pageNum));
}
async openAddDialog(): Promise<void> {
await this.click(this.locators.addBtn, '新 增');
await expect(this.shared.dialog).toBeVisible();
await expect(this.shared.dialog).toContainText('新增公告');
await expect(this.locators.modalTitleInput).toBeVisible();
await expect(this.shared.dialog.getByText('发布范围')).toBeVisible();
await expect(this.shared.dialog.getByText('置顶')).toBeVisible();
await expect(this.shared.dialog.getByText('截止日期')).toBeVisible();
await expect(this.locators.richTextEditor).toBeVisible();
await expect(this.shared.dialog.getByRole('button', { name: /取\s*消/ })).toBeVisible();
await expect(this.shared.dialog.getByRole('button', { name: /发\s*布/ })).toBeVisible();
}
async addAndCancel(title: string): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.modalTitleInput, title, '填写新增标题');
await this.locators.richTextEditor.fill('自动化取消用例正文');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await expect(this.shared.dialog).toBeHidden({ timeout: 8_000 });
await this.queryByTitle(title);
await this.expectMockEmpty();
}
async publishAndCleanup(): Promise<void> {
const title = uniqNoticeTitle();
await this.openAddDialog();
await this.fill(this.locators.modalTitleInput, title, '填写新增标题');
await this.shared.dialog.getByText('中心库', { exact: true }).click();
await this.locators.richTextEditor.fill('自动化发布用例正文');
await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布');
await expect(this.shared.dialog).toBeHidden({ timeout: 15_000 });
await this.queryByTitle(title);
const row = await this.firstRow();
expect(await this.cellText(row, '公告标题')).toContain(title);
expect(await this.cellText(row, '发布范围')).toContain('中心库');
await row.getByRole('button', { name: /删\s*除/ }).click();
const confirm = this.page.getByRole('button', { name: /确\s*定/ }).last();
if (await confirm.isVisible().catch(() => false)) {
await this.click(confirm, '确定删除测试公告');
}
await this.waitForTableReady();
}
async publishWithEmptyTitle(): Promise<void> {
await this.openAddDialog();
await this.locators.richTextEditor.fill(basicHospitalData.mockNoticeBody);
await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async publishWithEmptyContent(): Promise<void> {
await this.openAddDialog();
await this.fill(this.locators.modalTitleInput, basicHospitalData.mockEmptyContentTitle, '填写 mock 标题');
await this.locators.richTextEditor.fill('');
await this.click(this.shared.dialog.getByRole('button', { name: /发\s*布/ }), '发 布');
await this.expectFormError();
await expect(this.shared.dialog).toBeVisible();
await this.closeDialogIfOpen();
}
async openEditAndExpectBackfill(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toContainText('编辑公告');
const value = await this.locators.modalTitleInput.inputValue();
expect(value.replace(/\s/g, '')).toBe(sample.sampleNoticeTitle.replace(/\s/g, ''));
await this.closeDialogIfOpen();
}
async editAndCancel(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
await expect(this.shared.dialog).toBeVisible();
const uniq = uniqNoticeTitle();
await this.fill(this.locators.modalTitleInput, uniq, '改标题');
await this.click(this.shared.dialog.getByRole('button', { name: /取\s*消/ }), '取 消');
await (await this.firstRow()).getByRole('button', { name: /编\s*辑/ }).click();
expect(await this.locators.modalTitleInput.inputValue()).toBe(sample.sampleNoticeTitle);
await this.closeDialogIfOpen();
}
async deleteConfirmAndCancel(): Promise<void> {
const sample = await this.extractSample();
await (await this.firstRow()).getByRole('button', { name: /删\s*除/ }).click();
const box = this.page.locator('.ant-modal, .ant-popconfirm, .ant-modal-confirm').filter({ hasText: /提示/ }).last();
await expect(box).toBeVisible();
await expect(box.getByRole('button', { name: /取\s*消/ })).toBeVisible();
await expect(box.getByRole('button', { name: /确\s*定/ })).toBeVisible();
await this.click(box.getByRole('button', { name: /取\s*消/ }), '取消删除');
await this.queryByTitle(sample.sampleNoticeTitle);
expect(await this.getDataRowCount()).toBeGreaterThan(0);
}
}
export const BASIC_NOTICE_PATH = basicHospitalData.noticePath;
import { expect, type Locator, type Page } from '@playwright/test';
import { userData } from '../../data/user.data';
import { BasicRoleLocators } from '../../locators/spd/basic-role.locator';
import { BasicUserSharedPage } from './basic-user-shared.page';
/** 角色管理:左右分栏 + 权限树;断言在 tests/spd/basic-role.spec.ts。 */
export class BasicRolePage extends BasicUserSharedPage {
override readonly locators: BasicRoleLocators;
constructor(page: Page) {
super(page, {
path: userData.rolePath,
pathPart: '/basic/user/platform/role',
menu: userData.menuRole,
forbidden: ['personCom', '/platform/person'],
});
this.locators = new BasicRoleLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.saveBtn.or(this.locators.roleTree).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
leftRoleItems(): Locator {
return this.page.locator(
'.ant-layout-sider .ant-table-tbody tr.ant-table-row, .ant-layout-sider .ant-list-item, .ant-layout-sider .ant-menu-item, [class*="role"] .ant-table-row',
);
}
async leftRoleNames(): Promise<string[]> {
const items = this.leftRoleItems();
const count = await items.count();
if (count > 0) {
const names: string[] = [];
for (let i = 0; i < Math.min(count, 30); i += 1) {
const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim();
if (text) names.push(text);
}
if (names.length) return names;
}
const fallback = this.page.locator('.ant-table-tbody tr.ant-table-row td').first();
if (await fallback.isVisible().catch(() => false)) {
return (await this.page.locator('.ant-table-tbody tr.ant-table-row td:first-child').allTextContents())
.map((t) => t.trim())
.filter(Boolean);
}
return [];
}
async sampleRoleName(): Promise<string> {
const names = await this.leftRoleNames();
const found = names.find((n) => n && n !== '角色');
if (!found) {
await this.skipWhenDataInsufficient('左栏无角色');
return '';
}
return found;
}
async selectRole(name: string): Promise<void> {
const item = this.leftRoleItems().filter({ hasText: name }).first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, `选中角色 ${name}`);
} else {
await this.click(this.page.getByText(name, { exact: true }).first(), `选中角色 ${name}`);
}
await this.waitSpinGone();
await this.waitSettle(400);
}
async searchRole(keyword: string): Promise<void> {
const input = this.locators.roleSearch;
if (!(await input.isVisible().catch(() => false))) {
const fallback = this.page.locator('input').first();
await this.fill(fallback, keyword, '角色名称搜索');
} else {
await this.fill(input, keyword, '角色名称搜索');
}
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.waitSettle(400);
}
async clearRoleSearch(): Promise<void> {
const input = this.locators.roleSearch.or(this.page.locator('input').first());
await input.fill('');
await this.page.keyboard.press('Enter').catch(() => undefined);
await this.waitSettle(400);
}
async isRoleSelected(name: string): Promise<boolean> {
const item = this.leftRoleItems().filter({ hasText: name }).first();
const cls = ((await item.getAttribute('class').catch(() => '')) ?? '').toLowerCase();
const aria = (await item.getAttribute('aria-selected').catch(() => '')) ?? '';
if (/selected|active/.test(cls) || aria === 'true') return true;
const selected = this.page.locator('.ant-table-row-selected, .ant-menu-item-selected, [aria-selected="true"]');
return (await selected.filter({ hasText: name }).count()) > 0;
}
async checkedSignature(): Promise<string> {
const nodes = this.locators.roleTree.locator('.ant-tree-treenode');
const limit = Math.min(await nodes.count(), 40);
const parts: string[] = [];
for (let i = 0; i < limit; i += 1) {
const title = ((await nodes.nth(i).locator('.ant-tree-title').innerText().catch(() => '')) ?? '').trim();
if (!title) continue;
const checked = (await nodes.nth(i).locator('.ant-tree-checkbox-checked').count()) > 0;
parts.push(`${title}:${checked ? 1 : 0}`);
}
return parts.join('|');
}
async expandTreeNode(title: string): Promise<void> {
const node = this.locators.roleTree
.locator('.ant-tree-treenode')
.filter({ has: this.page.locator('.ant-tree-title').filter({ hasText: new RegExp(`^\\s*${title}\\s*$`) }) })
.first();
if (!(await node.isVisible().catch(() => false))) return;
const switcher = node.locator('.ant-tree-switcher').first();
const closed = await switcher
.evaluate((el) => el.classList.contains('ant-tree-switcher_close'))
.catch(() => false);
if (closed) await this.click(switcher, `展开 ${title}`);
await this.waitSettle(200);
}
async isTreeChecked(title: string): Promise<boolean> {
const box = this.locators.treeCheckbox(title);
const cls = (await box.getAttribute('class').catch(() => '')) ?? '';
return cls.includes('ant-tree-checkbox-checked');
}
async isTreeDisabled(title: string): Promise<boolean> {
const box = this.locators.treeCheckbox(title);
const cls = (await box.getAttribute('class').catch(() => '')) ?? '';
return cls.includes('ant-tree-checkbox-disabled');
}
async checkModule(moduleName = '厂商管理'): Promise<void> {
const box = this.locators.treeCheckbox(moduleName);
if (!(await box.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient(`权限树无「${moduleName}」节点`);
return;
}
if (await this.isTreeDisabled(moduleName)) {
await this.skipWhenDataInsufficient(`「${moduleName}」勾选被锁定`);
return;
}
await this.click(box, `勾选 ${moduleName}`);
await this.waitSettle(300);
}
async refreshTree(): Promise<void> {
await this.click(this.locators.refreshBtn, '刷 新');
await this.waitSpinGone();
await this.waitSettle(300);
}
async deleteEntry(roleName: string): Promise<Locator> {
const row = this.leftRoleItems().filter({ hasText: roleName }).first();
return row
.getByRole('button', { name: /删/ })
.or(row.locator('.anticon-delete, [aria-label*="delete"], [aria-label*="删"]'))
.first();
}
async openDeleteConfirm(): Promise<{ roleName: string; confirm: Locator } | null> {
const names = await this.leftRoleNames();
const target = names.find((n) => n && !/管理员/.test(n)) ?? names[0];
if (!target) {
await this.skipWhenDataInsufficient('无可删除角色');
return null;
}
await this.selectRole(target);
const del = await this.deleteEntry(target);
if (!(await del.isVisible().catch(() => false))) {
const globalDel = this.page.locator('.anticon-delete, [aria-label*="delete"]').first();
if (!(await globalDel.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('无删除入口');
return null;
}
await this.click(globalDel, '删除');
} else {
await this.click(del, '删除');
}
const confirm = this.page
.locator('.ant-modal-confirm, .ant-popconfirm, .ant-modal')
.filter({ hasText: /提示|删除|确认/ })
.last();
await expect(confirm, '删除确认框').toBeVisible({ timeout: 8_000 });
return { roleName: target, confirm };
}
async openAddRoleForm(): Promise<{ before: number; opened: boolean }> {
const add = this.locators.addRoleBtn.or(this.button('新增').first());
if (!(await add.isVisible().catch(() => false))) {
await this.skipWhenDataInsufficient('新增角色入口不可见');
return { before: 0, opened: false };
}
const before = (await this.leftRoleNames()).length;
await this.click(add, '新增角色');
await this.waitSettle(400);
const layer = this.dialog().or(this.page.locator('.ant-modal:visible, .ant-drawer-open'));
const opened = await layer.first().isVisible().catch(() => false);
return { before, opened };
}
async cancelAddRole(): Promise<void> {
const layer = this.dialog().or(this.page.locator('.ant-modal:visible'));
if (await layer.first().isVisible().catch(() => false)) {
await this.closeDialog();
return;
}
await this.page.keyboard.press('Escape');
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { basicHospitalData } from '../../data/basic-hospital.data';
import { BasicSharedLocators } from '../../locators/spd/basic-shared.locator';
/** 医院资料页共用:无 iframe、菜单进入、表格/分页/弹窗。 */
export class BasicHospitalSharedPage extends BasePage {
protected readonly shared: BasicSharedLocators;
constructor(page: Page) {
super(page);
this.shared = new BasicSharedLocators(page);
}
async openPath(path: string): Promise<void> {
await this.goto(path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.assertPageHealthy(`打开 ${path}`);
expect(this.page.url(), 'storageState 失效回到登录页').not.toMatch(/\/login/);
}
async openViaMenu(menuName: string, pathPart: string): Promise<void> {
await this.goto(env.homePath);
await this.waitSpinGone();
await this.click(this.shared.menuBasic, '展开基础设置');
const hospital = this.shared.menuHospital;
if (await hospital.isVisible().catch(() => false)) {
await this.click(hospital, '展开医院资料');
}
await this.click(this.shared.menuItem(menuName), `进入${menuName}`);
await this.page.waitForURL((url) => url.pathname.includes(pathPart), { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(400);
}
async expectUrlContains(part: string): Promise<void> {
expect(this.page.url(), `URL 应含 ${part}`).toContain(part);
}
async expectNotOnSiblingPages(forbidden: string[]): Promise<void> {
const url = this.page.url();
for (const part of forbidden) {
expect(url, `不应进入 ${part}`).not.toContain(part);
}
}
dataRows(): Locator {
return this.shared.dataRows;
}
async getDataRowCount(): Promise<number> {
return this.dataRows().count();
}
async getTotalCount(): Promise<number> {
const text = (await this.shared.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async waitForTableReady(): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
}
async expectListOrEmpty(): Promise<void> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
if (rows > 0) {
expect(await this.getTotalCount(), '有数据时分页总数应 ≥ 1').toBeGreaterThanOrEqual(1);
return;
}
const emptyVisible = await this.shared.empty.isVisible().catch(() => false);
const emptyText = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(emptyVisible || emptyText || (await this.getTotalCount()) === 0, '无数据时空态应明确').toBeTruthy();
}
async expectHeaders(names: string[]): Promise<void> {
const text = ((await this.shared.tableHeader.allTextContents()) ?? []).join(' ');
for (const name of names) {
expect(text, `表头应含「${name}」`).toContain(name);
}
}
async headerIndex(name: string): Promise<number> {
const headers = await this.shared.tableHeader.allTextContents();
const index = headers.findIndex((item) => item.replace(/\s/g, '').includes(name.replace(/\s/g, '')));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async firstRow(): Promise<Locator> {
const row = this.dataRows().first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
async expectFormError(): Promise<void> {
const error = this.shared.formError.first();
const toast = this.page.locator('.ant-message-error, .ant-notification-notice').first();
await expect(error.or(toast), '应出现必填或非法提示').toBeVisible({ timeout: 8_000 });
}
async closeDialogIfOpen(): Promise<void> {
const dialog = this.shared.dialog;
if (!(await dialog.isVisible().catch(() => false))) return;
const cancel = dialog.getByRole('button', { name: /取\s*消/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹窗');
} else {
await this.page.keyboard.press('Escape');
}
await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
}
async selectDropdown(select: Locator, optionText: string): Promise<void> {
await this.click(select, '打开下拉');
const option = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option').filter({ hasText: optionText }).first();
await this.click(option, `选择 ${optionText}`);
await this.waitSettle(200);
}
async clickPaginationPage(pageNum: number): Promise<void> {
const item = this.page.locator(`.ant-pagination-item-${pageNum}`).first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, `翻到第 ${pageNum} 页`);
return;
}
await this.click(this.shared.nextPage, '下一页');
}
async hasPaginationItem(pageNum: number): Promise<boolean> {
return this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false);
}
formItem(label: string): Locator {
return this.page.locator('.ant-form-item').filter({ hasText: label }).first();
}
dialogFormItem(label: string): Locator {
return this.shared.dialog.locator('.ant-form-item').filter({ hasText: label }).first();
}
async pickCascaderFirstPath(): Promise<void> {
const selector = this.shared.dialog.locator('.ant-cascader, .ant-select').filter({ has: this.page.getByText('请选择') }).first();
const fallback = this.dialogFormItem('所在地区').locator('.ant-select, .ant-cascader').first();
const target = (await selector.count()) ? selector : fallback;
await this.click(target, '打开所在地区');
for (let i = 0; i < 3; i += 1) {
const item = this.page.locator('.ant-cascader-menu:visible .ant-cascader-menu-item:not(.ant-cascader-menu-item-disabled)').first();
if (!(await item.isVisible().catch(() => false))) break;
await this.click(item, `选择地区第 ${i + 1} 级`);
await this.waitSettle(150);
}
}
uniqStamp(prefix: string): string {
return `${prefix}-${Date.now()}`;
}
get hospitalData() {
return basicHospitalData;
}
}
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { BasicUserSharedLocators } from '../../locators/spd/basic-user-shared.locator';
import { skipIfInsufficientForPage2 } from './helpers/pagination-page2.helper';
export type UserMeta = {
path: string;
pathPart: string;
menu: string;
forbidden?: string[];
};
export type UserListSnapshot = {
rows: number;
total: number;
emptyVisible: boolean;
};
/** 用户管理三页共用:无 iframe、侧栏叶子菜单、表格/弹窗。 */
export class BasicUserSharedPage extends BasePage {
readonly locators: BasicUserSharedLocators;
constructor(
page: Page,
protected readonly meta: UserMeta,
) {
super(page);
this.locators = new BasicUserSharedLocators(page);
}
get pagePath(): string {
return this.meta.path;
}
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.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.userSubmenuTitle.first();
const submenu = this.page.locator('.ant-menu-submenu').filter({ has: title }).first();
const opened = await submenu
.evaluate((el) => el.classList.contains('ant-menu-submenu-open'))
.catch(() => false);
if (!opened) await this.click(title, '展开用户管理');
await this.click(this.locators.menuLeaf(this.meta.menu), `进入${this.meta.menu}`);
await this.page.waitForURL((url) => {
const pathname = url.pathname;
if (!pathname.includes(this.meta.pathPart)) return false;
return !(this.meta.forbidden ?? []).some((token) => pathname.includes(token));
}, { timeout: 20_000 });
await this.waitSpinGone();
await this.waitSettle(600);
await this.waitContentReady();
await this.assertPageHealthy(`菜单进入${this.meta.menu}`);
}
async waitContentReady(): Promise<void> {
const markers = this.page
.locator('.ant-table-thead th, .ant-empty, .menu-role-tree, .ant-tree, [placeholder="姓名、登录账号"]')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
}
async waitForTableReady(): Promise<void> {
await this.waitSpinGone();
await this.waitSettle(200);
await this.assertPageHealthy('列表加载');
}
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();
}
dialog(): Locator {
return this.locators.dialog;
}
async headerCells(): Promise<Locator> {
await this.page
.locator('.ant-table-thead th')
.first()
.waitFor({ state: 'attached', timeout: 12_000 })
.catch(() => undefined);
return this.page.locator('.ant-table-thead').first().locator('th');
}
async headerTexts(): Promise<string[]> {
return (await (await this.headerCells()).allTextContents()).map((item) => item.replace(/\s/g, ''));
}
async headerIndex(name: string): Promise<number> {
const texts = await this.headerTexts();
const needle = name.replace(/\s/g, '');
const exact = texts.findIndex((item) => item === needle);
if (exact >= 0) return exact;
const index = texts.findIndex((item) => item.includes(needle));
expect(index, `找不到列「${name}」`).toBeGreaterThanOrEqual(0);
return index;
}
async listRows(): Promise<Locator> {
return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
async getDataRowCount(): Promise<number> {
return (await this.listRows()).count();
}
async getTotalCount(): Promise<number> {
const text = (await this.locators.paginationTotal.textContent().catch(() => '')) ?? '';
const match = text.match(/共\s*(\d+)\s*条/);
return match ? Number(match[1]) : 0;
}
async cellText(row: Locator, header: string): Promise<string> {
const index = await this.headerIndex(header);
return ((await row.locator('td').nth(index).innerText()) ?? '').trim();
}
async tryCell(row: Locator, header: string): Promise<string> {
try {
return (await this.cellText(row, header)).trim();
} catch {
return '';
}
}
async firstRow(): Promise<Locator> {
const row = (await this.listRows()).first();
await expect(row, '列表至少应有 1 条数据').toBeVisible();
return row;
}
async snapshotList(): Promise<UserListSnapshot> {
await this.waitForTableReady();
const rows = await this.getDataRowCount();
const total = await this.getTotalCount();
const emptyVisible =
(await this.locators.empty.isVisible().catch(() => false)) ||
(await this.page.getByText('暂无数据').first().isVisible().catch(() => false));
return { rows, total, emptyVisible };
}
async sampleCell(header: string): Promise<string> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 12);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = await this.listRows();
const count = await rows.count();
if (count === 0) return false;
const limit = Math.min(count, sample);
for (let i = 0; i < limit; i += 1) {
const text = (await this.cellText(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
async fillName(keyword: string): Promise<void> {
await expect(this.locators.nameInput, '姓名/登录账号').toBeVisible();
await this.fill(this.locators.nameInput, keyword, '填写姓名或登录账号');
}
async selectFormOption(label: string, optionText: string): Promise<void> {
const select = this.locators.formSelect(label);
await expect(select, `筛选「${label}」`).toBeVisible();
await this.click(select, `打开${label}`);
const compact = optionText.replace(/\s/g, '').slice(0, 12);
const escaped = compact.slice(0, 8).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const option = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option')
.filter({ hasText: new RegExp(escaped) })
.first();
await this.click(option, `选择 ${optionText}`);
await this.waitSettle(200);
}
async expandAdvanced(): Promise<void> {
await this.click(this.locators.advancedFilter, '高级筛选');
await this.waitSettle(400);
}
rowAction(row: Locator, name: string): Locator {
const compact = name.replace(/\s/g, '');
const pattern = new RegExp(compact.split('').join('\\s*'));
return row.getByRole('button', { name: pattern }).or(row.getByText(pattern)).first();
}
async goToPage2(): Promise<{ firstLogin: string; secondLogin: string; active: string }> {
await skipIfInsufficientForPage2({
ensureHealthyBeforeSkip: (reason) => this.ensureHealthyBeforeSkip(reason),
waitForTableReady: () => this.waitForTableReady(),
getDataRowCount: () => this.getDataRowCount(),
hasPaginationItem: async (pageNum) =>
this.page.locator(`.ant-pagination-item-${pageNum}`).first().isVisible().catch(() => false),
});
const firstLogin = await this.tryCell(await this.firstRow(), '登录账号');
const 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 active = ((await this.page.locator('.ant-pagination-item-active').innerText()) ?? '').trim();
const secondLogin = await this.tryCell(await this.firstRow(), '登录账号');
return { firstLogin, secondLogin, active };
}
async openAddDialog(): Promise<Locator> {
await this.clickButton('新增');
const dialog = this.dialog();
await expect(dialog, '新增弹窗').toBeVisible({ timeout: 12_000 });
return dialog;
}
async openRowAction(name: string): Promise<Locator> {
const row = await this.firstRow();
await this.click(this.rowAction(row, name), name);
await this.waitSettle(400);
return row;
}
async closeDialog(): Promise<void> {
const dialog = this.dialog();
if (!(await dialog.isVisible().catch(() => false))) return;
const cancel = dialog.getByRole('button', { name: /取\s*消|关\s*闭/ }).first();
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹窗');
} else {
await this.page.keyboard.press('Escape');
}
await expect(dialog).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
}
async expectBlockedAndStay(): Promise<void> {
const hint = this.page.getByText(/必填|请输入|请选择|不能为空|不可为空|已存在|重复|占用/).first();
await expect(this.locators.formError.first().or(hint), '应出现拦截提示').toBeVisible({ timeout: 8_000 });
const stayed =
(await this.page.locator('.ant-modal:visible').count()) > 0 ||
(await this.page.locator('.ant-drawer-open').count()) > 0;
expect(stayed, '拦截后弹窗不应关闭').toBeTruthy();
}
async probeUnauthAccess(): Promise<{ onLogin: boolean; hasBusinessRow: boolean }> {
await this.page.goto(this.meta.path, { waitUntil: 'domcontentloaded' });
await this.page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined);
const onLogin = /\/login/i.test(this.page.url());
const hasBusinessRow = await this.page
.locator('.ant-table-tbody tr.ant-table-row, .menu-role-tree')
.first()
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
async fillDialogField(locator: Locator, value: string, label: string): Promise<void> {
if (!(await locator.isVisible().catch(() => false))) return;
await this.fill(locator, value, label);
}
}
import { type Page } from '@playwright/test';
import { userData, uniqLogin } from '../../data/user.data';
import { BasicUserUnionLocators } from '../../locators/spd/basic-user-union.locator';
import { BasicUserPage, type UserSample } from './basic-user.page';
export type UnionSample = UserSample & {
sampleHospital: string;
samplePhone: string;
};
/** 医共体用户管理:医院维度差异;断言在 tests/spd/basic-user-union.spec.ts。 */
export class BasicUserUnionPage extends BasicUserPage {
override readonly locators: BasicUserUnionLocators;
constructor(page: Page) {
super(page, {
path: userData.unionPath,
pathPart: '/basic/user/platform/personCom',
menu: userData.menuUnion,
forbidden: ['/platform/role'],
});
this.locators = new BasicUserUnionLocators(page);
}
async extractUnionSample(): Promise<UnionSample> {
await this.query();
const row = await this.firstRow();
const sampleUserName = await this.cellText(row, '姓名');
const sampleLoginName = await this.cellText(row, '登录账号');
const sampleHospital = await this.cellText(row, '所属医院');
const samplePhone = await this.tryCell(row, '联系方式');
const sampleDept = await this.tryCell(row, '所属科室');
const rawRole = await this.tryCell(row, '角色');
const sampleRole = rawRole.replace(/[((].*$/, '').replace(/…|\.{3}.*/, '').trim();
return { sampleUserName, sampleLoginName, sampleDept, sampleRole, sampleHospital, samplePhone };
}
async currentHospitalLabel(): Promise<string> {
const select = this.locators.formSelect('所属医院');
return ((await select.innerText().catch(() => '')) ?? '').replace(/\s/g, '');
}
async switchHospitalIfPossible(): Promise<{ from: string; to: string } | null> {
const from = await this.currentHospitalLabel();
await this.click(this.locators.formSelect('所属医院'), '打开所属医院');
const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option');
const count = await options.count();
if (count < 2) {
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.skipWhenDataInsufficient('所属医院仅 1 家可选');
return null;
}
let to = from;
for (let i = 0; i < count; i += 1) {
const text = ((await options.nth(i).innerText()) ?? '').replace(/\s/g, '');
if (text && text !== from) {
to = text;
await this.click(options.nth(i), `选择医院 ${text}`);
break;
}
}
if (to === from) {
await this.page.keyboard.press('Escape').catch(() => undefined);
await this.skipWhenDataInsufficient('所属医院无可切换选项');
return null;
}
await this.query();
return { from, to };
}
async queryComboUnion(sample: UnionSample): Promise<void> {
await this.fillName(sample.sampleUserName);
if (sample.sampleHospital) {
await this.selectFormOption('所属医院', sample.sampleHospital).catch(() => undefined);
}
const phoneInput = this.page.getByPlaceholder(/联系方式/).or(this.locators.formItem('联系方式').locator('input'));
if (sample.samplePhone && sample.samplePhone.length >= 4 && (await phoneInput.first().isVisible().catch(() => false))) {
await this.fill(phoneInput.first(), sample.samplePhone, '联系方式');
}
await this.query();
}
async addAndCancelOnHospital(): Promise<string> {
const login = uniqLogin();
await this.openAddDialog();
await this.fillAddRequired(login, '医共体取消');
await this.closeDialog();
await this.queryByName(login);
return login;
}
}
import { expect, type Page } from '@playwright/test';
import { userData, uniqLogin } from '../../data/user.data';
import { BasicUserLocators } from '../../locators/spd/basic-user.locator';
import { BasicUserSharedPage, type UserMeta } from './basic-user-shared.page';
export type UserSample = {
sampleUserName: string;
sampleLoginName: string;
sampleDept: string;
sampleRole: string;
};
/** 用户管理:动作与取数;断言在 tests/spd/basic-user.spec.ts。 */
export class BasicUserPage extends BasicUserSharedPage {
override readonly locators: BasicUserLocators;
constructor(page: Page, meta?: UserMeta) {
super(
page,
meta ?? {
path: userData.userPath,
pathPart: '/basic/user/platform/person',
menu: userData.menuUser,
forbidden: ['personCom', '/platform/role'],
},
);
this.locators = new BasicUserLocators(page);
}
async extractSample(): Promise<UserSample> {
await this.query();
const row = await this.firstRow();
const sampleUserName = await this.cellText(row, '姓名');
const sampleLoginName = await this.cellText(row, '登录账号');
const sampleDept = await this.sampleCell('所属科室');
const rawRole = await this.tryCell(row, '角色');
const sampleRole = rawRole.replace(/[((].*$/, '').replace(/…|\.{3}.*/, '').trim();
return { sampleUserName, sampleLoginName, sampleDept, sampleRole };
}
async queryByName(keyword: string): Promise<void> {
await this.fillName(keyword);
await this.query();
}
async queryByDept(dept: string): Promise<void> {
await this.selectFormOption('科室', dept);
await this.query();
}
async queryCombo(sample: UserSample): Promise<void> {
await this.fillName(sample.sampleUserName);
if (sample.sampleDept && sample.sampleDept !== '--') {
await this.selectFormOption('科室', sample.sampleDept);
}
if (sample.sampleRole) {
await this.selectFormOption('角色', sample.sampleRole).catch(() => undefined);
}
await this.query();
}
async queryMockEmpty(): Promise<void> {
await this.queryByName(userData.mockUser);
}
async filterByStatus(status: '启用' | '禁用'): Promise<void> {
await this.expandAdvanced();
const panel = this.page.locator('.ant-drawer-open, .ant-pro-table-search-query-filter, .advance-filter-panel').first();
const root = (await panel.isVisible().catch(() => false)) ? panel : this.page;
await this.selectFormOption('状态', status);
const filterBtn = this.locators.button('筛选', root).first();
if (await filterBtn.isVisible().catch(() => false)) {
await this.click(filterBtn, '筛 选');
} else {
await this.query();
}
await this.waitForTableReady();
}
async fillAddRequired(login: string, name: string): Promise<void> {
const dialog = this.dialog();
await this.fillDialogField(this.locators.dialogUserCode(), login, '账号');
await this.fillDialogField(this.locators.dialogName(), name, '姓名');
await expect(dialog.getByRole('button', { name: /确\s*定/ })).toBeVisible();
}
async addAndCancel(): Promise<string> {
const login = uniqLogin();
await this.openAddDialog();
await this.fillAddRequired(login, '自动化取消');
await this.closeDialog();
await this.queryByName(login);
return login;
}
async submitEmptyRequired(): Promise<void> {
await this.openAddDialog();
await this.click(this.dialog().getByRole('button', { name: /确\s*定/ }), '确 定');
}
async submitDuplicateLogin(login: string): Promise<void> {
await this.openAddDialog();
await this.fillAddRequired(login, '重复账号');
await this.click(this.dialog().getByRole('button', { name: /确\s*定/ }), '确 定');
}
async openEdit(): Promise<{ login: string; name: string }> {
const row = await this.firstRow();
const login = await this.cellText(row, '登录账号');
const name = await this.cellText(row, '姓名');
await this.click(this.rowAction(row, '编辑'), '编辑');
const dialog = this.dialog();
await expect(dialog, '编辑用户弹窗').toBeVisible({ timeout: 12_000 });
return { login, name };
}
async editAndCancel(): Promise<string> {
const { name } = await this.openEdit();
await this.fillDialogField(this.locators.dialogName(), `${name}-tmp`, '临时改姓名');
await this.closeDialog();
return name;
}
async collectRoleFilterOptions(): Promise<string[]> {
const select = this.locators.formSelect('角色');
if (!(await select.isVisible().catch(() => false))) return [];
await this.click(select, '打开角色下拉');
const options = this.page.locator('.ant-select-dropdown:visible .ant-select-item-option');
const texts = (await options.allTextContents())
.map((item) => item.replace(/\s/g, '').replace(/[((].*$/, ''))
.filter(Boolean);
await this.page.keyboard.press('Escape').catch(() => undefined);
return texts;
}
async authCheckedCount(): Promise<number> {
const tree = this.page.locator('.ant-tree, .menu-role-tree, .ant-drawer-open .ant-tree').first();
if (!(await tree.isVisible().catch(() => false))) return 0;
return tree.locator('.ant-tree-checkbox-checked').count();
}
}
......@@ -1603,7 +1603,8 @@ export class CenterReportSharedPage extends BasePage {
await executeExportReport({
page: this.page,
prepareRowSelection: options?.prepareRowSelection,
clickExport: () => this.click(exportBtn, `点击${tableLabel}导出`),
clickExport: () =>
this.click(exportBtn, `点击${tableLabel}导出`, { operation: 'write' }),
hasVisibleErrorNotice: () => this.hasVisibleErrorNotice(),
assertHealthy: () => this.assertPageHealthy('导出后'),
});
......@@ -1664,7 +1665,7 @@ export class CenterReportSharedPage extends BasePage {
async triggerBatchPrint(): Promise<void> {
const dialogPromise = this.page.waitForEvent('dialog', { timeout: 10_000 }).catch(() => null);
await this.click(this.locators.batchPrintBtn, '点击批量打印');
await this.click(this.locators.batchPrintBtn, '点击批量打印', { operation: 'write' });
const dialog = await dialogPromise;
if (dialog) {
await dialog.dismiss().catch(() => undefined);
......@@ -1734,7 +1735,7 @@ export class CenterReportSharedPage extends BasePage {
}
await expect(printBtn, '打印按钮应可点击').toBeEnabled({ timeout: 10_000 });
const dialogPromise = this.page.waitForEvent('dialog', { timeout: 10_000 }).catch(() => null);
await this.click(printBtn, '点击打 印');
await this.click(printBtn, '点击打 印', { operation: 'write' });
const dialog = await dialogPromise;
if (dialog) {
await dialog.dismiss().catch(() => undefined);
......
import { expect, type Page } from '@playwright/test';
import { flowData } from '../../data/flow.data';
import { FlowDefLocators } from '../../locators/spd/flow-def.locator';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 审批流程设置:动作与取数;断言在 tests/spd/flow-def.spec.ts。 */
export class FlowDefPage extends WhFlowSharedPage {
override readonly locators: FlowDefLocators;
constructor(page: Page) {
super(page, {
path: flowData.defPath,
pathPart: 'workflow/flowSet',
group: flowData.menuGroup,
menu: flowData.menuDef,
forbidden: ['flowCase'],
});
this.locators = new FlowDefLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.typeSearch
.or(this.locators.defaultFlow)
.or(this.locators.saveBtn)
.first()
.waitFor({ state: 'visible', timeout: 20_000 })
.catch(() => undefined);
await this.waitSpinGone();
}
async typeNames(): Promise<string[]> {
const items = this.locators.typeItems();
const count = await items.count();
const names: string[] = [];
if (count > 0) {
for (let i = 0; i < Math.min(count, 40); i += 1) {
const text = ((await items.nth(i).innerText()) ?? '').split('\n')[0].trim();
if (text && !['流程名称', '审批流程设置'].includes(text)) names.push(text);
}
if (names.length) return names;
}
const fallback = this.page.locator('.ant-layout-sider, [class*="left"]').locator('span, div, li');
const n = Math.min(await fallback.count(), 60);
for (let i = 0; i < n; i += 1) {
const text = ((await fallback.nth(i).innerText()) ?? '').trim();
if (text && text.length <= 16 && /计划|单|申请|变更|调拨|准入/.test(text)) names.push(text);
}
return [...new Set(names)];
}
async selectedTypeName(): Promise<string> {
const selected = this.locators.selectedType();
if (await selected.isVisible().catch(() => false)) {
return ((await selected.innerText()) ?? '').split('\n')[0].trim();
}
const names = await this.typeNames();
return names[0] ?? '';
}
async filterTypes(keyword: string): Promise<void> {
await this.fill(this.locators.typeSearch, keyword, '填写流程名称');
await this.waitSettle(400);
}
async clearTypeFilter(): Promise<void> {
await this.fill(this.locators.typeSearch, '', '清空流程名称');
await this.waitSettle(400);
}
async clickType(name: string): Promise<void> {
const item = this.locators.typeItems().filter({ hasText: name }).first()
.or(this.page.getByText(name, { exact: true }).first());
await this.click(item, `选中类型 ${name}`);
await this.waitSpinGone();
await this.waitSettle(400);
}
async switchOtherType(): Promise<string | null> {
const current = await this.selectedTypeName();
const names = await this.typeNames();
const other = names.find((name) => name && name !== current);
if (!other) {
await this.skipWhenDataInsufficient('业务类型仅 1 个');
return null;
}
await this.clickType(other);
return other;
}
async defaultFlowText(): Promise<string> {
const region = this.page.locator('section, div').filter({ hasText: '默认审批流' }).first();
return ((await region.innerText().catch(() => '')) ?? '').replace(/\s/g, '');
}
async customRowCount(): Promise<number> {
return this.getDataRowCount();
}
async addFlowRow(): Promise<void> {
await this.click(this.locators.addFlowBtn, '新增流程');
await this.waitSettle(300);
}
async clickSave(): Promise<void> {
await this.click(this.locators.saveBtn, '保 存', { operation: 'write' });
await this.waitSettle(400);
}
async clickReset(): Promise<void> {
await this.click(this.locators.resetBtn, '重 置');
await this.waitSpinGone();
await this.waitSettle(400);
}
async openFirstCondition(): Promise<{ before: string; opened: boolean }> {
const rows = await this.listRows();
if ((await rows.count()) === 0) {
await this.skipWhenDataInsufficient('当前类型无自定义流');
return { before: '', opened: false };
}
const row = rows.first();
const before = ((await row.innerText()) ?? '').replace(/\s/g, '');
await this.click(this.rowAction(row, '编辑'), '条件编辑');
const dialog = this.locators.dialog();
const opened = await dialog.isVisible().catch(() => false);
await expect(dialog, '条件配置').toBeVisible({ timeout: 12_000 });
return { before, opened };
}
async cancelCondition(): Promise<void> {
await this.closeOverlay();
}
async firstCustomRowText(): Promise<string> {
const rows = await this.listRows();
if ((await rows.count()) === 0) return '';
return ((await rows.first().innerText()) ?? '').replace(/\s/g, '');
}
}
import { expect, type Page } from '@playwright/test';
import { flowData } from '../../data/flow.data';
import { FlowInstLocators } from '../../locators/spd/flow-inst.locator';
import { WhFlowSharedPage } from './wh-flow-shared.page';
/** 审批流实例:动作与取数;断言在 tests/spd/flow-inst.spec.ts。 */
export class FlowInstPage extends WhFlowSharedPage {
override readonly locators: FlowInstLocators;
constructor(page: Page) {
super(page, {
path: flowData.instPath,
pathPart: 'workflow/flowCase',
group: flowData.menuGroup,
menu: flowData.menuInst,
forbidden: ['flowSet'],
});
this.locators = new FlowInstLocators(page);
}
override async waitContentReady(): Promise<void> {
await this.locators.orderNo.or(this.locators.queryBtn).first().waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitSpinGone();
}
async fillOrderNo(value: string): Promise<void> {
await this.fill(this.locators.orderNo, value, '填写单据编号');
}
async fillSummary(value: string): Promise<void> {
await this.fill(this.locators.summary, value, '填写摘要');
}
async queryByOrderNo(orderNo: string): Promise<void> {
await this.fillOrderNo(orderNo);
await this.query();
}
async queryByStatus(status: string): Promise<void> {
await this.selectFormOption('审批状态', status);
await this.query();
}
async queryByBizType(type: string): Promise<void> {
await this.selectFormOption('业务类型', type);
await this.query();
}
async queryCombo(status: string, type: string): Promise<void> {
await this.selectFormOption('审批状态', status);
await this.selectFormOption('业务类型', type);
await this.query();
}
async searchMockEmpty(): Promise<void> {
await this.queryByOrderNo(flowData.mockFlow);
}
async dateRangeValues(): Promise<{ start: string; end: string }> {
return {
start: ((await this.locators.startDate.inputValue().catch(() => '')) ?? '').trim(),
end: ((await this.locators.endDate.inputValue().catch(() => '')) ?? '').trim(),
};
}
async sampleSummaryKeyword(): Promise<{ keyword: string; usedMock: boolean }> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 10);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), '摘要')).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') {
return { keyword: text.slice(0, Math.min(text.length, 8)), usedMock: false };
}
}
return { keyword: flowData.mockFlow, usedMock: true };
}
async prefixTypeSamples(): Promise<Array<{ prefix: string; type: string; expected: string }>> {
const rows = await this.listRows();
const count = Math.min(await rows.count(), 20);
const found: Array<{ prefix: string; type: string; expected: string }> = [];
const seen = new Set<string>();
for (let i = 0; i < count; i += 1) {
const orderNo = await this.tryCell(rows.nth(i), '单据编号');
const type = await this.tryCell(rows.nth(i), '业务类型');
for (const [prefix, expected] of Object.entries(flowData.prefixType)) {
if (orderNo.startsWith(prefix) && !seen.has(prefix)) {
seen.add(prefix);
found.push({ prefix, type, expected });
}
}
}
return found;
}
async openFirstDetail(): Promise<{ orderNo: string; layerText: string }> {
const row = await this.firstRow();
const orderNo = await this.tryCell(row, '单据编号');
await this.click(this.rowAction(row, '详情'), '详情');
const layer = this.overlay().or(this.page.locator('.ant-page-header, .ant-descriptions'));
await expect(layer.first(), '审批详情').toBeVisible({ timeout: 15_000 });
await this.waitSpinGone();
const layerText = ((await this.page.locator('body').innerText()) ?? '').replace(/\s/g, '');
return { orderNo, layerText };
}
async closeDetail(): Promise<void> {
const back = this.locators.backBtn.first().or(this.page.getByRole('button', { name: /关\s*闭|返\s*回/ }).first());
if (await back.isVisible().catch(() => false)) {
await this.click(back, '返回');
} else {
await this.closeOverlay();
}
await this.waitForTableReady();
}
async hasSaveOnDetail(): Promise<boolean> {
const save = this.page.getByRole('button', { name: /保\s*存|提交审批|改条件/ });
if (!(await save.first().isVisible().catch(() => false))) return false;
return save.first().isEnabled().catch(() => false);
}
async firstOrderNo(): Promise<string> {
return this.tryCell(await this.firstRow(), '单据编号');
}
}
......@@ -24,6 +24,10 @@ export type ReuseOpenOptions = {
/**
* 同页复用 open:beforeAll 只导航一次,beforeEach 复位筛选。
*
* ⚠️ 仅用于存量报表加速。组内共享同一 BrowserContext/Page,与「每用例独立 context」原则冲突。
* 厂商 / 准入 / 医院资料等新模块禁止使用;应走 auth.setup + storageState 文件 + 默认 page fixture。
*
* 组内顺序执行,但前序失败不跳过后续(避免 serial 把无关用例整组未跑)。
* 失败后重建会话,避免脏页拖垮下一条。
* 用例内通过 getReport() 取 Page Object,勿使用默认 page fixture。
......
import { expect, type Locator, type Page } from '@playwright/test';
import { surgeryData, uniqBasePkgCode, uniqBasePkgName } from '../../data/surgery.data';
import { SurgerySharedPage, type SurgeryMeta } from './surgery-shared.page';
const META: SurgeryMeta = {
path: surgeryData.basePkgPath,
pathPart: surgeryData.basePkgPathPart,
menu: surgeryData.menuBasePkg,
siblings: [
{ name: surgeryData.menuOperType, pathPart: surgeryData.operTypePathPart },
{ name: surgeryData.menuStdPkg, pathPart: surgeryData.stdPkgPathPart },
],
forbidden: [surgeryData.operTypePathPart, surgeryData.stdPkgPathPart],
};
/** 基础包维护(CSV SURG-BASE-001..022)。 */
export class SurgeryBasePkgPage extends SurgerySharedPage {
constructor(page: Page) {
super(page, META);
}
// ---- 定位 ----
categorySearch() {
return this.page.getByPlaceholder('请输入分类名称').first();
}
categoryTree() {
return this.locators.leftTree;
}
addCategoryBtn() {
return this.locators.button('新增分类').first();
}
addBasePkgBtn() {
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');
}
// ---- 左树分类 ----
/** CSV SURG-BASE-001:按分类名称过滤左树。 */
async searchCategory(keyword: string): Promise<void> {
await this.fill(this.categorySearch(), keyword, `填写分类名称「${keyword}」`);
await this.waitSettle(600);
}
/** CSV SURG-BASE-003:打开新增分类弹窗。 */
async openAddCategoryDialog(): Promise<void> {
await this.click(this.addCategoryBtn(), '新增分类');
await this.expectDialogVisible();
await this.waitSpinGone();
}
categoryNameField(): Locator {
return this.locators.byId('#form_item_kindName');
}
// ---- 筛选 ----
/** CSV SURG-BASE-006/007/008:按列首行样例筛选。 */
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-BASE-013:mock 反例空态。 */
async searchMockEmpty(): Promise<void> {
await this.fillFilterById('#table_search_form_keyword', surgeryData.mockBaseKeyword);
await this.query();
}
/** CSV SURG-BASE-010:过滤后重置并返回筛选框取值。 */
async filterThenReset(): Promise<{ before: number; keyword: string; 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, keyword, afterValue };
}
// ---- 新增基础包 ----
/** CSV SURG-BASE-014:打开新增基础包弹窗。 */
async openAddBasePkgDialog(): Promise<void> {
await this.click(this.addBasePkgBtn(), '新增基础包');
await this.expectDialogVisible();
await this.waitSpinGone();
}
pkgNameField(): Locator {
return this.locators.byId('#form_item_pkgName');
}
pkgCategoryField(): Locator {
return this.locators.byId('#form_item_kindId');
}
pkgCodeField(): Locator {
return this.locators.byId('#form_item_pkgCode');
}
remarkField(): Locator {
return this.locators.byId('#form_item_remark');
}
/** 填名称/所属分类/包编码(CSV SURG-BASE-016 前置)。 */
async fillAddForm(opts: { name: string; code: string; withCategory?: boolean }): Promise<void> {
await this.fill(this.pkgNameField(), opts.name, '填写基础包名称');
if (opts.withCategory !== false) {
await this.selectInDialog('所属分类');
}
await this.fill(this.pkgCodeField(), opts.code, '填写包编码');
}
/** CSV SURG-BASE-015:新增后取消,返回 { total, newName }。 */
async addThenCancel(): Promise<{ total: number; newName: string }> {
const total = await this.getTotalCount();
const newName = uniqBasePkgName();
await this.openAddBasePkgDialog();
await this.fill(this.pkgNameField(), newName, '填写基础包名称');
await this.closeDialogIfOpen();
return { total, newName };
}
/** CSV SURG-BASE-016/019:新增成功,返回新增名称。 */
async addSuccess(opts?: { name?: string; code?: string; doubleConfirm?: boolean }): Promise<string> {
const name = opts?.name ?? uniqBasePkgName();
const code = opts?.code ?? uniqBasePkgCode();
await this.openAddBasePkgDialog();
await this.fillAddForm({ name, code });
if (opts?.doubleConfirm) {
// 连点两次:第二次可能因弹窗已关而落空,用短超时点击且忽略失败,只验证最终仅落一条
const confirm = this.dialogButton('确 认').or(this.locators.confirmLikeButton(this.dialog()));
await confirm.click({ timeout: 1_500 }).catch(() => undefined);
await confirm.click({ timeout: 1_500 }).catch(() => undefined);
await this.waitSpinGone();
} else {
await this.clickConfirm('确 认');
}
await expect(this.dialog(), '新增成功后弹窗应关闭').toBeHidden({ timeout: 15_000 });
await this.waitForTableReady();
return name;
}
/** CSV SURG-BASE-017/018:仅名称/不选分类的必填拦截。 */
async addWithMissing(opts: { name?: string; code?: string; withCategory?: boolean }): Promise<void> {
await this.openAddBasePkgDialog();
if (opts.name) await this.fill(this.pkgNameField(), opts.name, '填写基础包名称');
if (opts.withCategory) await this.selectInDialog('所属分类');
if (opts.code) await this.fill(this.pkgCodeField(), opts.code, '填写包编码');
await this.clickConfirm('确 认');
}
/** 新增后按名称检索并断言可见(用例层再断言)。 */
async searchByName(name: string): Promise<void> {
await this.fillFilterById('#table_search_form_keyword', name);
await this.query();
}
/** 后置清理:按名称检索后移除(写操作,二次确认)。 */
async cleanupByName(name: string): Promise<void> {
await this.searchByName(name);
const row = await this.rowWithAction('移 除');
if (!row) return;
await this.click(this.rowAction(row, '移 除'), '移 除');
const confirm = this.dialogButton('确 定').or(this.locators.confirmLikeButton(this.dialog()));
if (await confirm.isVisible().catch(() => false)) {
await this.click(confirm, '确认移除', { operation: 'write' });
await this.waitForTableReady();
} else {
await this.page.keyboard.press('Escape').catch(() => undefined);
}
}
// ---- 行内操作 ----
/** CSV SURG-BASE-020:首行编辑并回填,返回首行产品名称。 */
async openEditFirstRow(): Promise<string> {
const name = await this.tryCell(await this.firstRow(), '产品名称');
await this.click(this.rowAction(await this.firstRow(), '编 辑'), '编 辑');
await this.expectDialogVisible();
await this.waitSpinGone();
return name;
}
/** CSV SURG-BASE-021:首行移除并取消,返回 { total, cancelShown }。 */
async removeFirstRowThenCancel(): Promise<{ total: number; cancelShown: boolean }> {
const total = await this.getTotalCount();
const row = await this.firstRow();
await this.click(this.rowAction(row, '移 除'), '移 除');
const cancel = this.locators.cancelLikeButton(this.dialog());
const cancelShown = await cancel.isVisible({ timeout: 5_000 }).catch(() => false);
if (cancelShown) await this.closeDialogIfOpen();
return { total, 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_BASE_PKG_PATH = surgeryData.basePkgPath;
import type { Locator, Page } from '@playwright/test';
import { surgeryData, uniqOperName } from '../../data/surgery.data';
import { SurgerySharedPage, type SurgeryMeta } from './surgery-shared.page';
const META: SurgeryMeta = {
path: surgeryData.operTypePath,
pathPart: surgeryData.operTypePathPart,
menu: surgeryData.menuOperType,
siblings: [
{ name: surgeryData.menuBasePkg, pathPart: surgeryData.basePkgPathPart },
{ name: surgeryData.menuStdPkg, pathPart: surgeryData.stdPkgPathPart },
],
forbidden: [surgeryData.basePkgPathPart, surgeryData.stdPkgPathPart],
};
/** 术种维护(CSV SURG-TYPE-001..025)。 */
export class SurgeryOperTypePage extends SurgerySharedPage {
constructor(page: Page) {
super(page, META);
}
// ---- 定位 ----
categorySearch() {
return this.page.getByPlaceholder(/请输入.*术种分类名称|请输入分类名称/).first();
}
addBtn() {
return this.locators.button('新 增').or(this.locators.button('新增')).first();
}
addOperTypeBtn() {
return this.locators.button('新增术种').first();
}
operationNameInput() {
return this.filterById('#table_search_form_operationName');
}
diseaseInput() {
return this.filterById('#table_search_form_disease');
}
statusTab(name: string): Locator {
return this.page
.locator('.ant-radio-button-wrapper, .ant-tabs-tab, .ant-segmented-item')
.filter({ hasText: new RegExp(name) })
.first()
.or(this.locators.tab(name));
}
// ---- 左树分类 ----
async searchCategory(keyword: string): Promise<void> {
await this.fill(this.categorySearch(), keyword, `填写术种分类名称「${keyword}」`);
await this.waitSettle(600);
}
/** CSV SURG-TYPE-007:打开分类维护弹窗。 */
async openCategoryDialog(): Promise<void> {
await this.click(this.addBtn(), '新增');
await this.expectDialogVisible();
await this.waitSpinGone();
}
categoryNameField(): Locator {
return this.locators.byId('#basic_kindName');
}
// ---- 筛选 ----
/** CSV SURG-TYPE-009:按术种名称筛选。 */
async searchByOperationName(): Promise<string> {
return this.searchByFirstRow('#table_search_form_operationName', '术种名称');
}
/** CSV SURG-TYPE-010:按病种名称筛选。 */
async searchByDisease(): Promise<string> {
return this.searchByFirstRow('#table_search_form_disease', '病种名称');
}
/** CSV SURG-TYPE-014:mock 反例空态。 */
async searchMockEmpty(): Promise<void> {
await this.fillFilterById('#table_search_form_operationName', surgeryData.mockOperKeyword);
await this.query();
}
/** CSV SURG-TYPE-012:左树 + 筛选 + Tab 组合过滤。 */
async combinedFilter(tabName = surgeryData.statusAll): Promise<string> {
const category = await this.clickFirstTreeNode();
const keyword = await this.sampleCell('术种名称');
await this.fillFilterById('#table_search_form_operationName', keyword);
await this.query();
await this.switchStatusTab(tabName);
return `${category}|${keyword}`;
}
/** CSV SURG-TYPE-013:过滤后重置,返回筛选框是否清空。 */
async filterThenReset(): Promise<{ before: number; afterValue: string }> {
const keyword = await this.sampleCell('术种名称');
await this.fillFilterById('#table_search_form_operationName', keyword);
await this.query();
const before = await this.getTotalCount();
await this.reset();
const afterValue = await this.operationNameInput().inputValue().catch(() => '');
return { before, afterValue };
}
// ---- 状态 Tab ----
/** 读取状态 Tab 括号计数(如「停用(3)」)。 */
async tabCount(name: string): Promise<number | null> {
const text = ((await this.statusTab(name).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const match = text.match(/[((](\d+)[))]/) ?? text.match(/(\d+)/);
return match ? Number(match[1]) : null;
}
async switchStatusTab(name: string): Promise<void> {
await this.click(this.statusTab(name), `切换状态 Tab「${name}」`);
await this.waitForTableReady();
}
/** CSV SURG-TYPE-011:读取各状态 Tab 计数与列表总数。 */
async statusTabCounts(): Promise<{
all: number | null;
disabled: number | null;
unclassified: number | null;
}> {
const result = {
all: await this.tabCount(surgeryData.statusAll),
disabled: await this.tabCount(surgeryData.statusDisabled),
unclassified: await this.tabCount(surgeryData.statusUnclassified),
};
return result;
}
// ---- 列表与编码(CSV SURG-TYPE-016/017)----
async firstIcd10Code(): Promise<string> {
return this.sampleCell('诊断编码(ICD-10)');
}
async firstIcd9Code(): Promise<string> {
return this.sampleCell('操作编码(ICD-9-CM-3)');
}
async firstIcd10Name(): Promise<string> {
return this.sampleCell('诊断名称(ICD-10)');
}
async firstIcd9Name(): Promise<string> {
return this.sampleCell('操作名称(ICD-9-CM-3)');
}
// ---- 新增术种 ----
/** CSV SURG-TYPE-020:打开新增术种入口。 */
async openAddOperType(): Promise<void> {
await this.click(this.addOperTypeBtn(), '新增术种');
await this.waitSpinGone();
await this.waitSettle(400);
}
/** CSV SURG-TYPE-021:新增后取消,返回 { total, newName }。 */
async addThenCancel(): Promise<{ total: number; newName: string }> {
const total = await this.getTotalCount();
const newName = uniqOperName();
await this.openAddOperType();
const nameField = this.dialogFormItem('术种名称').locator('input').first();
if (await nameField.isVisible().catch(() => false)) {
await this.fill(nameField, newName, '填写术种名称');
}
await this.closeDialogIfOpen();
return { total, newName };
}
/** CSV SURG-TYPE-022:名称为空的必填拦截(返回弹窗是否仍在、拦截后总数)。 */
async addWithEmptyName(): Promise<{ dialogStillOpen: boolean; total: number }> {
const total = await this.getTotalCount();
await this.openAddOperType();
await this.clickConfirm('保 存');
const dialogStillOpen = await this.dialog().isVisible().catch(() => false);
return { dialogStillOpen, total };
}
// ---- 行内操作 ----
/** CSV SURG-TYPE-023:首行编辑并回填,返回首行术种名称。 */
async openEditFirstRow(): Promise<string> {
const name = await this.tryCell(await this.firstRow(), '术种名称');
await this.click(this.rowAction(await this.firstRow(), '编 辑'), '编 辑');
await this.expectDialogVisible();
await this.waitSpinGone();
return name;
}
/**
* CSV SURG-TYPE-024:首行停用并确认。
* 返回停用前「停用」Tab 计数、是否出现确认框、停用后计数,供用例断言计数 +1。
*/
async disableFirstRowConfirmed(): Promise<{
before: number | null;
confirmShown: boolean;
after: number | null;
}> {
const before = await this.tabCount(surgeryData.statusDisabled);
const row = (await this.rowWithAction('停 用')) ?? (await this.rowWithAction('停用'));
if (!row) {
await this.skipWhenDataInsufficient('无「停用」可操作行');
}
await this.click(this.rowAction(row as Locator, '停 用').or(this.rowAction(row as Locator, '停用')), '停用');
const confirm = this.dialogButton('确 定').or(this.locators.confirmLikeButton(this.dialog()));
const confirmShown = await confirm.isVisible({ timeout: 5_000 }).catch(() => false);
if (confirmShown) {
await this.click(confirm, '确认停用', { operation: 'write' });
await this.waitForTableReady();
}
const after = await this.tabCount(surgeryData.statusDisabled);
return { before, confirmShown, after };
}
/** 后置:对目标术种「启用」恢复。 */
async enableByName(name: string): Promise<void> {
await this.fillFilterById('#table_search_form_operationName', name);
await this.query();
const row = (await this.rowWithAction('启 用')) ?? (await this.rowWithAction('启用'));
if (!row) return;
await this.click(this.rowAction(row, '启 用').or(this.rowAction(row, '启用')), '启用');
const confirm = this.dialogButton('确 定').or(this.locators.confirmLikeButton(this.dialog()));
if (await confirm.isVisible({ timeout: 5_000 }).catch(() => false)) {
await this.click(confirm, '确认启用', { operation: 'write' });
await this.waitForTableReady();
}
}
/** CSV SURG-TYPE-025:首行停用后取消,返回 { before, cancelShown }。 */
async disableFirstRowThenCancel(): Promise<{ before: number | null; cancelShown: boolean }> {
const before = await this.tabCount(surgeryData.statusDisabled);
const row = (await this.rowWithAction('停 用')) ?? (await this.rowWithAction('停用')) ?? (await this.firstRow());
await this.click(this.rowAction(row, '停 用').or(this.rowAction(row, '停用')).or(this.rowAction(row, '启 用')), '停用');
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 };
}
}
export const SURGERY_OPER_TYPE_PATH = surgeryData.operTypePath;
import { expect, type Locator, type Page } from '@playwright/test';
import { env } from '../../config/env.config';
import { BasePage } from '../../core/base.page';
import { SurgerySharedLocators } from '../../locators/spd/surgery-shared.locator';
export type SurgeryMeta = {
/** 直达路径 */
path: string;
/** URL 片段(断言用) */
pathPart: string;
/** 侧栏叶子菜单名 */
menu: string;
/** 同组页签其它两页,用于跨页只读跳转 */
siblings: { name: string; pathPart: string }[];
/** 不应误入的 URL 片段 */
forbidden: string[];
};
/** 左树快照(供用例层断言) */
export type TreeSnapshot = { nodes: number; texts: string[] };
/**
* 手术套包设置三页共用:菜单进入、左树、筛选、列表、分页、弹窗。
* 断言留在 spec,本类只做动作与取数。
*/
export class SurgerySharedPage extends BasePage {
readonly locators: SurgerySharedLocators;
constructor(
page: Page,
protected readonly meta: SurgeryMeta,
) {
super(page);
this.locators = new SurgerySharedLocators(page);
}
get pagePath(): string {
return this.meta.path;
}
// ---------- 页面进入 ----------
/** 直达页面(含模块健康检查与登录态校验)。 */
async open(): Promise<void> {
await this.goto(this.meta.path);
await this.waitSpinGone();
await this.waitSettle(400);
await this.waitContentReady();
await this.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 opened = await this.locators.surgerySubmenuTitle
.evaluate((el) => el.closest('.ant-menu-submenu')?.classList.contains('ant-menu-submenu-open') ?? false)
.catch(() => false);
if (!opened) await this.click(this.locators.surgerySubmenuTitle, '展开手术套包设置');
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-tree, .ant-table-thead th, .ant-table-header th, thead th, .ant-empty')
.first();
await markers.waitFor({ state: 'visible', timeout: 20_000 }).catch(() => undefined);
await this.waitForTableReady();
}
/** 断言已在目标页,且未误入同组其它页。 */
async expectEntered(): 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 hasTree = await this.locators.leftTree.isVisible().catch(() => false);
const hasHeader = (await this.locators.tableHeader.count()) > 0;
const hasEmpty = await this.page.getByText('暂无数据').first().isVisible().catch(() => false);
expect(hasTree || hasHeader || hasEmpty, '左侧分类树、列表表头或空态至少其一可见').toBeTruthy();
}
// ---------- 同组页签 ----------
async switchTab(name: string, expectPathPart?: string): Promise<void> {
await this.click(this.locators.tab(name), `点击 Tab「${name}」`);
if (expectPathPart) {
await this.page.waitForURL((url) => url.pathname.includes(expectPathPart), { timeout: 20_000 }).catch(() => undefined);
}
await this.waitSpinGone();
await this.waitSettle(500);
await this.waitContentReady();
await this.assertPageHealthy(`切换 Tab「${name}」`);
}
async expectTabPath(pathPart: string): Promise<void> {
expect(this.page.url(), `Tab 切换后 URL 应含 ${pathPart}`).toContain(pathPart);
}
/** 未登录直达探测:返回是否被拦截(用例层断言)。 */
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')
.first()
.isVisible()
.catch(() => false);
return { onLogin, hasBusinessRow };
}
// ---------- 左侧分类树 ----------
treeNodes(): Locator {
return this.locators.treeNodes();
}
async getTreeCount(): Promise<number> {
return this.treeNodes().count();
}
/** 左树快照(节点数 + 文本)。 */
async snapshotTree(): Promise<TreeSnapshot> {
await this.waitSettle(200);
const nodes = this.locators.treeNodes();
const count = await nodes.count();
const texts: string[] = [];
for (let i = 0; i < count; i += 1) {
texts.push(((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, ''));
}
return { nodes: count, texts };
}
async firstTreeNodeText(): Promise<string> {
const snap = await this.snapshotTree();
const hit = snap.texts.find((t) => t && t !== '-' && t !== '--');
if (!hit) {
await this.skipWhenDataInsufficient('左树分类为空');
}
return hit ?? '';
}
/** 读取首个带括号计数的节点:名称与计数(CSV SURG-TYPE-006)。 */
async firstCountedTreeNode(): Promise<{ name: string; count: number } | null> {
const nodes = this.locators.treeNodes();
const count = Math.min(await nodes.count(), 30);
for (let i = 0; i < count; i += 1) {
const text = ((await nodes.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
const match = text.match(/^(.*?)[((](\d+)[))]$/);
if (match && match[1]) {
return { name: match[1], count: Number(match[2]) };
}
}
return null;
}
async searchTree(keyword: string): Promise<void> {
const input = this.treeSearch();
await this.fill(input, keyword, `填写分类名称搜索「${keyword}」`);
await input.press('Enter').catch(() => undefined);
await this.waitSettle(600);
}
async clearTreeSearch(): Promise<void> {
const input = this.treeSearch();
await input.fill('');
await input.press('Enter').catch(() => undefined);
await this.waitSettle(600);
}
/** 树过滤框:按 placeholder 精确优先,回退到左树临近输入。 */
treeSearch(): Locator {
const byPlaceholder = this.page.getByPlaceholder(/请输入.*分类名称/).first();
return byPlaceholder.or(this.locators.treeSearchInput);
}
async clickTreeNode(name: string): Promise<void> {
await this.click(this.locators.treeNode(name), `选择分类「${name}」`);
await this.waitForTableReady();
}
/** 选择首个分类节点并返回其名称(用例层断言用)。 */
async clickFirstTreeNode(): Promise<string> {
const name = await this.firstTreeNodeText();
await this.clickTreeNode(name);
return name;
}
// ---------- 右侧筛选 ----------
/** CSV 明确给出筛选控件 id 时优先用 id。 */
filterById(id: string): Locator {
return this.locators.byId(id);
}
/** 通用筛选:优先 id,回退 placeholder/label。 */
filterInput(opts: { id?: string; placeholder?: string; label?: string }): Locator {
if (opts.id) {
const byId = this.locators.byId(opts.id);
return byId;
}
if (opts.placeholder) return this.page.getByPlaceholder(opts.placeholder).first();
const label = opts.label ?? '';
return this.page
.locator('.ant-form-item')
.filter({ hasText: label })
.locator('input')
.first();
}
async fillFilterById(id: string, keyword: string): Promise<void> {
await this.fill(this.filterById(id), keyword, `填写筛选「${id}」`);
}
async query(): Promise<void> {
await this.click(this.locators.button('查 询').first(), '查 询');
await this.waitForTableReady();
}
async reset(): Promise<void> {
await this.click(this.locators.button('重 置').first(), '重 置');
await this.waitForTableReady();
}
/** 按首行某列样例值填写筛选并查询;返回关键字(用例层断言用)。 */
async searchByFirstRow(id: string, header: string): Promise<string> {
const keyword = await this.sampleCell(header);
await this.fillFilterById(id, keyword);
await this.query();
return keyword;
}
// ---------- 列表取数 ----------
dataRows(): Locator {
return this.page.locator('.ant-table-tbody > tr.ant-table-row:not(.ant-table-measure-row)');
}
async getDataRowCount(): Promise<number> {
return this.dataRows().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 headerCells(): Promise<Locator> {
await this.locators.tableHeader.first().waitFor({ state: 'attached', timeout: 12_000 }).catch(() => undefined);
return this.locators.tableHeader;
}
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 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 = this.dataRows().first();
await expect(row, '列表至少应有 1 条数据').toBeVisible({ timeout: 15_000 });
return row;
}
/** 首屏某列样例值(跳过占位),数据不足时 skip。 */
async sampleCell(header: string): Promise<string> {
const rows = this.dataRows();
const count = Math.min(await rows.count(), 8);
for (let i = 0; i < count; i += 1) {
const text = (await this.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (text && text !== '--' && text !== '-') return text;
}
await this.skipWhenDataInsufficient(`首屏「${header}」为空`);
return '';
}
/** 抽样行某列是否均含关键字(用例层断言)。 */
async rowsMatchKeyword(header: string, keyword: string, sample = 5): Promise<boolean> {
const needle = keyword.replace(/\s/g, '').slice(0, 12);
const rows = this.dataRows();
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.tryCell(rows.nth(i), header)).replace(/\s/g, '');
if (!text.includes(needle)) return false;
}
return true;
}
/** 列表中是否存在包含关键字的行(跨展开/分组)。 */
async hasRowContaining(keyword: string): Promise<boolean> {
const rows = this.dataRows();
const count = await rows.count();
for (let i = 0; i < count; i += 1) {
const text = ((await rows.nth(i).innerText().catch(() => '')) ?? '').replace(/\s/g, '');
if (text.includes(keyword.replace(/\s/g, ''))) return true;
}
return false;
}
/** 列表快照供用例层断言(不在此 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 };
}
// ---------- 分页 ----------
async currentPage(): Promise<number> {
const text = (await this.locators.paginationActive.innerText().catch(() => '')) ?? '';
const match = text.match(/\d+/);
return match ? Number(match[0]) : 1;
}
async prevDisabled(): Promise<boolean> {
const cls = (await this.locators.paginationPrev.getAttribute('class').catch(() => '')) ?? '';
if (/disabled/.test(cls)) return true;
return this.locators.paginationPrev
.getAttribute('aria-disabled')
.then((v) => v === 'true')
.catch(() => false);
}
/** 切换每页条数,返回 { total, rowsOnPage }。 */
async changePageSize(size: number): Promise<{ total: number; rowsOnPage: number }> {
const total = await this.getTotalCount();
const select = this.page.locator('.ant-pagination-options .ant-select').first();
await this.click(select, '打开每页条数');
const option = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option')
.filter({ hasText: new RegExp(`^\\s*${size}\\s*条\\s*/\\s*页`) })
.first();
if (await option.isVisible().catch(() => false)) {
await this.click(option, `选择每页 ${size} 条`);
} else {
await this.page.keyboard.press('Escape');
await this.skipWhenDataInsufficient(`无每页 ${size} 条选项`);
}
await this.waitForTableReady();
return { total, rowsOnPage: await this.getDataRowCount() };
}
async goToPage2(): Promise<void> {
const item = this.page.locator('.ant-pagination-item-2').first();
if (await item.isVisible().catch(() => false)) {
await this.click(item, '翻到第 2 页');
} else if (!(await this.prevDisabled())) {
await this.click(this.locators.paginationNext, '下一页');
}
await this.waitForTableReady();
}
// ---------- 行内操作 ----------
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 = this.dataRows();
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;
}
// ---------- 弹窗 ----------
dialog(): Locator {
return this.locators.dialog;
}
dialogButton(name: string): Locator {
return this.locators.button(name, this.dialog()).first();
}
dialogFormItem(label: string): Locator {
return this.dialog().locator('.ant-form-item').filter({ hasText: label }).first();
}
formItem(label: string): Locator {
return this.page.locator('.ant-form-item').filter({ hasText: label }).first();
}
/** 弹窗内下拉选择:按 label 找到表单项中的 ant-select,选择首个可选项。 */
async selectInDialog(label: string): Promise<void> {
const select = this.dialogFormItem(label).locator('.ant-select').first();
await this.click(select, `打开「${label}」下拉`);
const option = this.page
.locator('.ant-select-dropdown:visible .ant-select-item-option:not(.ant-select-item-option-disabled)')
.first();
if (await option.isVisible().catch(() => false)) {
await this.click(option, `选择「${label}」首项`);
} else {
await this.page.keyboard.press('Escape');
}
await this.waitSettle(200);
}
/** 弹窗内级联选择首条路径(分类树)。 */
async pickCascaderFirstPath(): Promise<void> {
const target = this.dialog().locator('.ant-cascader, .ant-select').first();
await this.click(target, '打开级联选择');
for (let i = 0; i < 4; i += 1) {
const item = this.page
.locator('.ant-cascader-menu:visible .ant-cascader-menu-item:not(.ant-cascader-menu-item-disabled)')
.first();
if (!(await item.isVisible().catch(() => false))) break;
await this.click(item, `选择第 ${i + 1} 级`);
await this.waitSettle(150);
}
}
async expectFormError(): Promise<void> {
const error = this.locators.formError.first();
await expect(error, '应出现必填或非法提示').toBeVisible({ timeout: 8_000 });
}
async expectDialogVisible(): Promise<void> {
await expect(this.dialog(), '弹窗应可见').toBeVisible({ timeout: 15_000 });
}
async closeDialogIfOpen(): Promise<void> {
if (!(await this.dialog().isVisible().catch(() => false))) return;
const cancel = this.locators.cancelLikeButton(this.dialog());
if (await cancel.isVisible().catch(() => false)) {
await this.click(cancel, '关闭弹窗');
} else {
await this.page.keyboard.press('Escape');
}
await expect(this.dialog()).toBeHidden({ timeout: 8_000 }).catch(() => undefined);
}
/** 点确定类按钮(写操作,不盲目重试)。 */
async clickConfirm(label = '确 认'): Promise<void> {
await this.click(this.dialogButton(label).or(this.locators.confirmLikeButton(this.dialog())), label, {
operation: 'write',
});
await this.waitSpinGone();
}
}
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