214 lines
9.9 KiB
Python
214 lines
9.9 KiB
Python
from framework.core.logger import get_logger
|
|
import time
|
|
|
|
logger = get_logger("BasePage")
|
|
|
|
class BasePage:
|
|
"""所有页面对象的基类,提供智能识别与常用操作封装"""
|
|
|
|
def __init__(self, page):
|
|
self.page = page
|
|
|
|
def smart_find(self, description, role=None, timeout=5000):
|
|
"""核心智能定位器:按优先级尝试语义定位"""
|
|
strategies = [
|
|
lambda: self.page.get_by_role(role, name=description, exact=False) if role else None,
|
|
lambda: self.page.get_by_text(description, exact=False),
|
|
# 新增:直接属性扫描以应对 aria-label 类按钮
|
|
lambda: self.page.locator(f'[aria-label*="{description}"]').first,
|
|
lambda: self.page.get_by_placeholder(description, exact=False),
|
|
lambda: self.page.get_by_label(description, exact=False),
|
|
lambda: self.page.get_by_title(description, exact=False)
|
|
]
|
|
|
|
for get_loc in strategies:
|
|
try:
|
|
loc = get_loc()
|
|
if loc and loc.count() > 0:
|
|
return loc.first
|
|
except:
|
|
continue
|
|
|
|
# 兜底:尝试 CSS
|
|
if any(c in description for c in [".", "#", "[", ">"]):
|
|
try:
|
|
return self.page.locator(description).first
|
|
except:
|
|
pass
|
|
|
|
raise Exception(f"❌ 无法在页面找到元素: '{description}'")
|
|
|
|
def smart_click(self, text, role=None, timeout=5000):
|
|
"""语义化点击:带重试与可见性校验"""
|
|
logger.info(f"👉 [SmartClick] 尝试点击: {text}")
|
|
try:
|
|
el = self.smart_find(text, role=role, timeout=timeout)
|
|
# 等待可见与稳定
|
|
el.wait_for(state="visible", timeout=timeout)
|
|
|
|
# 使用自带重试的点击逻辑,应对 SPA 瞬时刷新
|
|
try:
|
|
el.click(timeout=timeout)
|
|
except Exception as e:
|
|
if "detached" in str(e).lower():
|
|
logger.warning(f"⚠️ 节点失效,正在重新寻找并点击: {text}")
|
|
self.smart_find(text, role=role, timeout=timeout).click(timeout=timeout)
|
|
else:
|
|
raise e
|
|
except Exception as e:
|
|
# 暴力循环 JS 点击作为最终兜底
|
|
success = self.page.evaluate(f"""(txt) => {{
|
|
const target = Array.from(document.querySelectorAll('button, a, span, .p-button-label, label, input'))
|
|
.find(el => {{
|
|
const it = el.innerText.trim();
|
|
const al = el.getAttribute('aria-label') || "";
|
|
const ph = el.getAttribute('placeholder') || "";
|
|
return it.includes(txt) || al.includes(txt) || ph.includes(txt);
|
|
}});
|
|
if (target) {{
|
|
target.scrollIntoView({{behavior: "smooth", block: "center"}});
|
|
setTimeout(() => target.click(), 100);
|
|
return true;
|
|
}}
|
|
return false;
|
|
}}""", text)
|
|
if not success:
|
|
raise e
|
|
|
|
def smart_fill(self, label, value, timeout=5000):
|
|
"""语义化输入:支持 Vue/React 的深度绑定注入"""
|
|
logger.info(f"⌨️ [SmartFill] 在 [{label}] 填写: {value}")
|
|
try:
|
|
# 1. 尝试直接命中 (Label/Placeholder等原生属性)
|
|
el = self.smart_find(label, timeout=timeout)
|
|
el.wait_for(state="visible", timeout=timeout)
|
|
el.fill(value, timeout=timeout)
|
|
except Exception as first_err:
|
|
try:
|
|
# 2. 邻近注入方案:借助 JS 寻找关联输入框,并使用 Playwright 原生 fill 保障前端框架双向绑定
|
|
logger.info(f"🔍 [SmartFill] 常规规则未命中,正在执行深度邻近探测 [{label}]...")
|
|
success = self.page.evaluate(f"""(lbl) => {{
|
|
document.querySelectorAll('[data-smart-target]').forEach(e => e.removeAttribute('data-smart-target'));
|
|
|
|
// 1. 过滤:必须是有高度/宽度的真实可见节点
|
|
const allNodes = Array.from(document.querySelectorAll('label, span, div, p, legend'))
|
|
.filter(el => el.offsetWidth > 0 && el.offsetHeight > 0 && el.innerText && el.innerText.includes(lbl));
|
|
|
|
// 2. 最深层过滤
|
|
const tags = allNodes.filter(el => {{
|
|
return !Array.from(el.children).some(child => child.offsetWidth > 0 && child.innerText && child.innerText.includes(lbl));
|
|
}});
|
|
|
|
for (const t of tags) {{
|
|
const container = t.closest('.p-inputgroup, .field, .flex, div');
|
|
const input = container?.querySelector('input:not([type="hidden"]), textarea') ||
|
|
t.parentElement?.querySelector('input:not([type="hidden"]), textarea');
|
|
if (input && input.offsetWidth > 0) {{
|
|
input.setAttribute('data-smart-target', 'true');
|
|
return true;
|
|
}}
|
|
}}
|
|
return false;
|
|
}}""", label)
|
|
|
|
if success:
|
|
target = self.page.locator("[data-smart-target='true']").first
|
|
target.scroll_into_view_if_needed()
|
|
target.fill(value, timeout=timeout)
|
|
target.blur()
|
|
else:
|
|
logger.warning(f"⚠️ [SmartFill] JS 探测失败: 页面上可见区域内没找到 '{label}' 及其相邻输入框。")
|
|
raise first_err
|
|
except Exception as second_err:
|
|
logger.error(f"⚠️ [SmartFill] JS注入或执行时发生错误: {second_err}")
|
|
raise second_err
|
|
|
|
def smart_delete(self,text,role=None,timeout=5000):
|
|
logger.info(f"👉 [SmartDelete] 尝试删除: {text}")
|
|
try:
|
|
el = self.smart_find(text, role=role, timeout=timeout)
|
|
el.wait_for(state="visible", timeout=timeout)
|
|
el.click(timeout=timeout)
|
|
except Exception as e:
|
|
logger.error(f"⚠️ [SmartDelete] JS注入或执行时发生错误: {e}")
|
|
raise e
|
|
|
|
def smart_select(self, label, value):
|
|
"""智能下拉框选择 (适配 PrimeVue)"""
|
|
logger.info(f"📋 [SmartSelect] 在 [{label}] 下拉框寻找目标: {value}")
|
|
|
|
# 1. 寻找并打开下拉框
|
|
opened = self.page.evaluate("""([lbl, val]) => {
|
|
const root = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]') || document;
|
|
const elements = Array.from(root.querySelectorAll('label, span, div'));
|
|
const targetLabel = elements.find(el => {
|
|
const t = el.innerText?.trim();
|
|
return t && (t === lbl || t.includes(lbl)) && el.offsetWidth > 0;
|
|
});
|
|
|
|
if (!targetLabel) return 'NO_LABEL';
|
|
|
|
let container = targetLabel;
|
|
for (let i = 0; i < 8; i++) {
|
|
if (!container) break;
|
|
// 查找下拉框容器
|
|
const dd = container.querySelector('.p-select, .p-dropdown, [data-pc-name="select"], [data-pc-name="dropdown"]');
|
|
if (dd && dd.offsetWidth > 0) {
|
|
dd.scrollIntoView({block: 'center'});
|
|
dd.click();
|
|
return 'OPENED';
|
|
}
|
|
// 检查兄弟元素
|
|
if (container.parentElement) {
|
|
const sibling = container.parentElement.querySelector('.p-select, .p-dropdown, [data-pc-name="select"]');
|
|
if (sibling && sibling.offsetWidth > 0) {
|
|
sibling.scrollIntoView({block: 'center'});
|
|
sibling.click();
|
|
return 'OPENED';
|
|
}
|
|
}
|
|
container = container.parentElement;
|
|
}
|
|
return 'NO_DROPDOWN';
|
|
}""", [label, value])
|
|
|
|
if opened != 'OPENED':
|
|
logger.warning(f"⚠️ [SmartSelect] 无法精准找到标签 '{label}' 关联的下拉框,尝试直接搜索首个 visible 的下拉框...")
|
|
self.page.locator(".p-select, .p-dropdown").first.click()
|
|
|
|
time.sleep(1)
|
|
|
|
# 2. 尝试多种定位器点击选项
|
|
try:
|
|
# 优先精准文本
|
|
selectors = [
|
|
f"li[role='option']:has-text('{value}')",
|
|
f".p-select-option:has-text('{value}')",
|
|
f".p-dropdown-item:has-text('{value}')"
|
|
]
|
|
for sel in selectors:
|
|
loc = self.page.locator(sel).first
|
|
if loc.is_visible(timeout=500):
|
|
loc.click()
|
|
logger.info(f"✅ [SmartSelect] 成功通过选择器选中: {value}")
|
|
return True
|
|
except:
|
|
pass
|
|
|
|
# 3. JS 暴力匹配并点击
|
|
clicked = self.page.evaluate("""(val) => {
|
|
const options = Array.from(document.querySelectorAll('li[role="option"], .p-select-option, .p-dropdown-item'));
|
|
const target = options.find(opt => opt.offsetWidth > 0 && opt.innerText.includes(val));
|
|
if (target) {
|
|
target.scrollIntoView({block: 'center'});
|
|
target.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true}));
|
|
return true;
|
|
}
|
|
return false;
|
|
}""", value)
|
|
|
|
if clicked:
|
|
logger.info(f"✅ [SmartSelect] 通过 JS 暴力匹配选中: {value}")
|
|
return True
|
|
|
|
raise Exception(f"❌ [SmartSelect] 无法在下拉框中选择选项: {value}") |