import time from framework.core.base_page import BasePage from framework.core.logger import get_logger logger = get_logger("CloudDesktopPage") class CloudDesktopPage(BasePage): """云桌面页面 POM - 纯原子操作层""" MENU_TEXT = "地瓜桌面" STATUS = { "RUNNING": "运行中", "STOPPED": "已关机", "CREATING": "创建中", "STARTING": "开机中" } def __init__(self, page): self.page = page def get_desktop_status(self, desktop_name): """获取指定名称桌面的全行内容""" return self.page.evaluate("""(name) => { const rows = Array.from(document.querySelectorAll('tr')); for (const row of rows) { const cells = row.querySelectorAll('td'); if (cells.length === 0) continue; // 逐列精确匹配名称 for (const cell of cells) { const t = cell.innerText.trim(); if (t === name) return row.innerText.trim(); } } // 兜底: 模糊匹配 const dataRow = rows.find(r => { const text = r.innerText; return text.includes(name) && text.trim().length > name.length + 5; }); if (dataRow) return dataRow.innerText.trim(); const anyRow = Array.from(document.querySelectorAll('.p-datatable-row, .p-selectable-row')) .find(r => r.innerText.includes(name)); return anyRow ? anyRow.innerText.trim() : 'Not Found'; }""", desktop_name) def wait_for_status(self, name, expected_status, timeout=600): """循环等待桌面达到指定状态""" logger.info(f"⏳ 等待桌面 {name} 状态变为: {expected_status}...") start = time.time() last_log = 0 while time.time() - start < timeout: current = self.get_desktop_status(name) if expected_status in current: logger.info(f"✅ 状态已达标: {expected_status}") return True if time.time() - last_log > 10: snippet = (current[:50] + '...') if len(current) > 50 else current logger.info(f" [状态巡检] {int(time.time()-start)}s | 当前内容: {snippet}") last_log = time.time() time.sleep(5) raise TimeoutError(f"桌面 {name} 在 {timeout}s 内未达到 {expected_status} 状态。最后看到: {current}") def navigate_to(self): """进入云桌面管理页面""" logger.info("正在切换到【地瓜桌面】页面...") try: self.smart_click(self.MENU_TEXT, timeout=3000) try: self.page.wait_for_url("**/*cloud*", timeout=3000) except: pass except Exception as e: logger.warning(f"原生导航失败,尝试兜底... {e}") self.page.evaluate(f"""() => {{ const target = Array.from(document.querySelectorAll('a, span, li, div')) .find(el => el.innerText && el.innerText.trim() === '{self.MENU_TEXT}'); if (target) {{ target.click(); if(target.parentElement) target.parentElement.click(); }} }}""") time.sleep(3) def get_first_desktop_name(self): """获取列表首行名称""" return self.page.evaluate("""() => { const cells = document.querySelectorAll('td, .card-name'); return cells.length > 0 ? cells[0].innerText.trim() : 'N/A'; }""") def open_create_dialog(self): """打开创建弹窗""" logger.info("👉 点击 [创建桌面] 按钮") self.smart_click("创建桌面") time.sleep(1) def fill_name(self, name): """输入桌面名称""" logger.info(f"⌨️ 输入桌面名称: {name}") self.smart_fill("名称", name) def select_sku(self, sku_id): """选择资源规格 (复用基类智能下拉框逻辑)""" logger.info(f"🎯 云桌面规格选择: {sku_id}") self.smart_select("资源规格", sku_id) def select_image(self, image_keyword="Ubuntu"): """选择镜像 (增加搜索与精准点击)""" logger.info(f"💿 选择镜像关键词: {image_keyword}") self.smart_click("选择镜像") time.sleep(2) # 等待列表对话框弹出 # 尝试使用搜索框 try: search_input = self.page.locator("input[placeholder*='搜索'], .p-inputtext").first if search_input.is_visible(): search_input.fill(image_keyword) self.page.keyboard.press("Enter") time.sleep(1.5) except: pass # 定位并点击包含关键词的镜像卡片/行 try: # 策略:在 dialog 中寻找包含文本的元素 target_selector = f".p-dialog :text-is('{image_keyword}'), .p-dialog :text('{image_keyword}')" self.page.locator(target_selector).first.click(timeout=5000) except: # 备选:智能点击 self.smart_click(image_keyword, timeout=3000) time.sleep(1) # 点击确定按钮 (弹窗中的) try: self.page.locator(".p-dialog-footer button:has-text('确定')").click() except: self.smart_click("确定", role="button") time.sleep(1) def submit_creation(self): """提交创建""" logger.info("🚀 提交创建请求") self.smart_click("创建并开机") time.sleep(3) # ── 行内操作通用方法 ───────────────────────────────────────────── def _click_row_action(self, desktop_name, action_label): """通用:点击指定桌面行内的操作按钮(精确行匹配版)""" logger.info(f"👉 在 {desktop_name} 行内寻找并点击 [{action_label}]") clicked = self.page.evaluate("""(args) => { const [name, action] = args; const rows = Array.from(document.querySelectorAll('tr')); let targetRow = null; // 精确匹配:遍历每行的每个 td,要求某列文字完全等于 name for (const row of rows) { const cells = row.querySelectorAll('td'); if (cells.length === 0) continue; for (const cell of cells) { const t = cell.innerText.trim(); if (t === name) { targetRow = row; break; } } if (targetRow) break; } // 兜底:如果精确匹配失败,再用 includes(仅第一列) if (!targetRow) { for (const row of rows) { const cells = row.querySelectorAll('td'); if (cells.length === 0) continue; const firstCell = cells[0].innerText.trim(); if (firstCell === name || firstCell.includes(name)) { targetRow = row; break; } } } if (!targetRow) return 'NO_ROW'; // 策略1:aria-label let btn = targetRow.querySelector(`button[aria-label="${action}"], [aria-label="${action}"]`); // 策略2:按钮/链接文字精确匹配 if (!btn) { btn = Array.from(targetRow.querySelectorAll('button, a, span, .p-button')) .find(el => { const txt = el.innerText.trim(); const al = el.getAttribute('aria-label') || ''; return txt === action || al === action; }); } if (btn) { btn.scrollIntoView({block: 'center'}); btn.click(); return 'CLICKED'; } return 'NO_BTN'; }""", [desktop_name, action_label]) logger.info(f" 行内按钮点击结果: {clicked}") if clicked == 'NO_ROW': raise Exception(f"❌ 表格中未找到包含 '{desktop_name}' 的行") if clicked == 'NO_BTN': logger.info(f" JS未命中,尝试 Playwright 定位(限定行内)...") # 用 Playwright 定位到目标行,再在行内找按钮 row_locator = self.page.locator(f"tr:has(td:text-is('{desktop_name}'))") try: row_locator.get_by_role("button", name=action_label).first.click(timeout=3000) except: # 最后兜底:在行内找包含操作文字的可点击元素 row_locator.locator(f"text='{action_label}'").first.click(timeout=3000) def _confirm_dialog(self, confirm_text="确定"): """确认弹窗 (支持 '预约', '确认', '确定' 等)""" logger.info(f"🔍 正在寻找确认按钮 (目标: {confirm_text})...") time.sleep(1.5) # 给弹窗动画留出充足时间 # 1. 优先尝试显式的定位器 (含截图中的 aria-label="预约") task_specific_selectors = [ "button[aria-label='预约']", "button[aria-label='确定']", ".p-confirm-dialog-accept", ".p-dialog-footer button:not(.p-button-secondary):not(.p-button-text)" ] for sel in task_specific_selectors: try: btn = self.page.locator(sel).first if btn.is_visible(timeout=500): btn.click() logger.info(f"✅ 通过选择器成功点击: {sel}") return except: continue # 2. 按文案智能匹配 keywords = [confirm_text, "预约", "确定", "确认", "提交", "立即"] for kw in keywords: try: # 尝试直接定位包含文字的 PrimeVue 按钮 btn = self.page.locator(f"button .p-button-label:text-is('{kw}'), button:has-text('{kw}')").first if btn.is_visible(timeout=500): btn.click() logger.info(f"✅ 通过文案成功点击: {kw}") return except: pass # JS 强力匹配 clicked = self.page.evaluate(f"""(txt) => {{ const btns = Array.from(document.querySelectorAll('.p-dialog button, button')) .filter(b => (b.innerText.includes(txt) || b.getAttribute('aria-label') === txt) && b.offsetWidth > 0); if (btns.length > 0) {{ btns[0].scrollIntoView({{block: 'center'}}); btns[0].click(); return true; }} return false; }}""", kw) if clicked: logger.info(f"✅ 通过 JS 模糊匹配成功点击: {kw}") return logger.warning(f"⚠️ 即使使用 '预约' 兜底也未能关闭确认窗口") time.sleep(1) # ── 业务操作 ───────────────────────────────────────────────────── def convert_to_monthly(self, desktop_name): """按量付费转包月 —— 精确匹配目标行内的按量付费图标""" self.wait_for_status(desktop_name, self.STATUS["RUNNING"]) logger.info(f"🎯 尝试转换桌面 {desktop_name} 为包月") clicked = self.page.evaluate("""(name) => { const rows = Array.from(document.querySelectorAll('tr')); let targetRow = null; // 精确匹配:遍历每行的每个 td,要求某列文字完全等于 name for (const row of rows) { const cells = row.querySelectorAll('td'); if (cells.length === 0) continue; for (const cell of cells) { const t = cell.innerText.trim(); if (t === name) { targetRow = row; break; } } if (targetRow) break; } // 兜底 if (!targetRow) { for (const row of rows) { const cells = row.querySelectorAll('td'); if (cells.length === 0) continue; const firstCell = cells[0].innerText.trim(); if (firstCell === name || firstCell.includes(name)) { targetRow = row; break; } } } if (!targetRow) return 'NO_ROW'; // 在目标行内找到"按量付费"所在的单元格 const cells = Array.from(targetRow.querySelectorAll('td')); for (const cell of cells) { if (cell.innerText.includes('按量')) { // 优先点击内部的可交互元素(图标/链接/按钮) // 注意:svg 元素没有 .click(),统一用 dispatchEvent const clickable = cell.querySelector('a, button, i, span[role="button"], .p-button') || cell.querySelector('svg') || cell.querySelector('span, div'); if (clickable) { clickable.scrollIntoView({block: 'center'}); clickable.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); return 'CLICKED_INNER'; } // 兜底:点击整个单元格 cell.scrollIntoView({block: 'center'}); cell.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true})); return 'CLICKED_CELL'; } } return 'NO_CELL'; }""", desktop_name) logger.info(f" 按量付费点击结果: {clicked}") if clicked == 'NO_ROW': raise Exception(f"❌ 表格中未找到 {desktop_name}") if clicked == 'NO_CELL': logger.warning(" 目标行内未找到按量付费,尝试全局搜索...") self.smart_click("按量付费", timeout=3000) time.sleep(1) self._confirm_dialog("确定") time.sleep(2) def open_desktop(self, desktop_name): """打开/开机桌面 (处理新开标签页)""" # 显式等待状态变为运行中,防止操作过快导致按钮没出现 self.wait_for_status(desktop_name, self.STATUS["RUNNING"]) logger.info(f"🎯 尝试打开桌面 {desktop_name}") # 捕获可能打开的新标签页 with self.page.context.expect_page(timeout=10000) as new_page_info: self._click_row_action(desktop_name, "打开桌面") new_page = new_page_info.value logger.info(f"🌐 检测到桌面已在新标签页打开: {new_page.url}") # 停留一会儿确保桌面加载,然后关掉它,回到控制台继续后续流程 time.sleep(60) new_page.close() logger.info("🔙 已关闭桌面标签页,返回控制台进行后续流程") time.sleep(5) def stop_desktop(self, desktop_name): """关机""" self.wait_for_status(desktop_name, self.STATUS["RUNNING"]) logger.info(f"🎯 尝试关机桌面 {desktop_name}") self._click_row_action(desktop_name, "关机") time.sleep(1) def delete_desktop(self, desktop_name): """删除桌面""" self.wait_for_status(desktop_name, self.STATUS["STOPPED"]) logger.info(f"🎯 尝试删除桌面 {desktop_name}") self._click_row_action(desktop_name, "删除") time.sleep(0.5) self._confirm_dialog("确定删除") time.sleep(2) def _select_dropdown(self, label_text, option_text): """通用下拉框选择 (强力触发 + 强力点击)""" logger.info(f"📋 下拉框 [{label_text}] 寻找目标: {option_text}") # 1. 寻找触发器并点击 (多重探测逻辑) triggered = self.page.evaluate(f"""(label) => {{ const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]') || document.body; // A. 通过 placeholder 文案尝试直接点击最明显的选择框 const placeholders = ["请选择", "选择", label]; for (const p of placeholders) {{ const boxes = Array.from(dialog.querySelectorAll('.p-select, .p-dropdown, [role="combobox"]')); const targetBox = boxes.find(b => b.innerText.includes(p) && b.offsetWidth > 0); if (targetBox) {{ targetBox.scrollIntoView({{block: 'center'}}); targetBox.click(); return 'BOX_CLICKED'; }} }} // B. 通过 Label 文字向上查找再向下查找 const labels = Array.from(dialog.querySelectorAll('label, .form-item-label, span')); const lbl = labels.find(el => el.innerText.trim().includes(label) && el.offsetWidth > 0); if (lbl) {{ let p = lbl; for (let i = 0; i < 5; i++) {{ const dd = p.querySelector('.p-select, .p-dropdown, [role="combobox"]'); if (dd) {{ dd.scrollIntoView({{block: 'center'}}); dd.click(); return 'LABEL_CLICKED'; }} p = p.parentElement; if (!p) break; }} }} return 'NOT_FOUND'; }}""", label_text) logger.info(f" 下拉框触发结果: {triggered}") time.sleep(1) # 2. 在【专属容器】内寻找并匹配选项 success = False keywords = [option_text] if "其他" in option_text or "Other" in option_text: keywords = ["其他", "其它", "Other", "Others", "other"] # 尝试 2 次,给渲染留时间 for attempt in range(2): clicked_res = self.page.evaluate(f"""(txts) => {{ const overlays = Array.from(document.querySelectorAll('.p-select-overlay, .p-select-panel, .p-dropdown-panel, .p-popover, .p-connected-overlay')); const containers = overlays.length > 0 ? overlays : [document.body]; for (const container of containers) {{ const options = Array.from(container.querySelectorAll('li, [role="option"], .p-select-option, .p-dropdown-item')); for (const opt of options) {{ const opText = (opt.innerText || opt.textContent).trim(); // 命中关键词 if (txts.some(t => opText === t || opText.includes(t))) {{ opt.scrollIntoView({{block: 'center', behavior: 'instant'}}); // 强力点击 opt.dispatchEvent(new MouseEvent('mousedown', {{ bubbles: true }})); opt.dispatchEvent(new MouseEvent('mouseup', {{ bubbles: true }})); opt.dispatchEvent(new MouseEvent('click', {{ bubbles: true }})); return true; }} }} }} return false; }}""", keywords) if clicked_res: logger.info(f" ✅ 已成功选中目标选项") success = True break time.sleep(1) if not success: logger.error(f"❌ 流程卡在下拉框选项点击: {label_text} -> {option_text}") # Playwright 原生点击兜底 try: self.page.locator(f"li:has-text('{option_text}'), [role='option']:has-text('{option_text}')").last.click(timeout=1000) success = True except: pass time.sleep(0.5) def _fill_tag_input(self, label_text, tag_value): """填写标签输入""" logger.info(f"🏷️ 标签输入 [{label_text}]: {tag_value}") # 找到标签区域的 "+" 按钮或输入框 found = self.page.evaluate("""(label) => { const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]'); if (!dialog) return 'NO_DIALOG'; const labels = Array.from(dialog.querySelectorAll('label, span, div')); const lbl = labels.find(el => el.innerText.trim().includes(label) && el.offsetWidth > 0); if (!lbl) return 'NO_LABEL'; let container = lbl; for (let i = 0; i < 6; i++) { if (!container) break; const addBtn = container.querySelector('button, .p-button, [role="button"]'); if (addBtn && addBtn.offsetWidth > 0) { addBtn.click(); return 'FOUND'; } container = container.parentElement; } return 'NO_BTN'; }""", label_text) logger.info(f" 标签输入框查找结果: {found}") time.sleep(0.8) # "+" 按钮点击后应该出现 input,填入标签值并回车 tag_filled = self.page.evaluate("""(tag) => { const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]'); if (!dialog) return 'NO_DIALOG'; const inputs = Array.from(dialog.querySelectorAll('input[type="text"], input:not([type])')) .filter(inp => !inp.readOnly && !inp.disabled && inp.offsetWidth > 0); for (let i = inputs.length - 1; i >= 0; i--) { const inp = inputs[i]; if (inp.value === '' || inp.placeholder?.includes('标签') || inp.placeholder?.includes('tag')) { const nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; nativeSet.call(inp, tag); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); inp.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); return 'FILLED'; } } return 'NO_TAG_INPUT'; }""", tag_value) logger.info(f" 标签输入结果: {tag_filled}") if tag_filled != 'FILLED': try: tag_input = self.page.locator('.p-dialog input').last tag_input.fill(tag_value) tag_input.press("Enter") except: logger.warning("⚠️ 标签输入兜底也失败") time.sleep(0.5) def save_image(self, desktop_name): """保存镜像 (完整弹窗交互)""" self.wait_for_status(desktop_name, self.STATUS["RUNNING"]) logger.info(f"🎯 尝试保存桌面 {desktop_name} 为镜像") self._click_row_action(desktop_name, "保存镜像") time.sleep(1.5) logger.info("📝 步骤1: 输入镜像名称") self.smart_fill("请输入镜像名称", f"AutoImage_{desktop_name}") time.sleep(0.5) logger.info("📝 步骤2: 选择任务类型") self._select_dropdown("任务类型", "其他") logger.info("📝 步骤3: 输入镜像标签") self._fill_tag_input("镜像标签", "auto-test") logger.info("📝 步骤4: 提交保存") self.smart_click("确定", role="button", timeout=5000) time.sleep(3) def _fill_tag_input(self, label_text, tag_value): """填写标签输入""" logger.info(f"🏷️ 标签输入 [{label_text}]: {tag_value}") # 找到标签区域的 "+" 按钮或输入框 found = self.page.evaluate("""(label) => { const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]'); if (!dialog) return 'NO_DIALOG'; const labels = Array.from(dialog.querySelectorAll('label, span, div')); const lbl = labels.find(el => el.innerText.trim().includes(label) && el.offsetWidth > 0); if (!lbl) return 'NO_LABEL'; let container = lbl; for (let i = 0; i < 6; i++) { if (!container) break; const addBtn = container.querySelector('button, .p-button, [role="button"]'); if (addBtn && addBtn.offsetWidth > 0) { addBtn.click(); return 'FOUND'; } container = container.parentElement; } return 'NO_BTN'; }""", label_text) logger.info(f" 标签输入框查找结果: {found}") time.sleep(0.8) # "+" 按钮点击后应该出现 input,填入标签值并回车 tag_filled = self.page.evaluate("""(tag) => { const dialog = document.querySelector('.p-dialog') || document.querySelector('[role="dialog"]'); if (!dialog) return 'NO_DIALOG'; const inputs = Array.from(dialog.querySelectorAll('input[type="text"], input:not([type])')) .filter(inp => !inp.readOnly && !inp.disabled && inp.offsetWidth > 0); for (let i = inputs.length - 1; i >= 0; i--) { const inp = inputs[i]; if (inp.value === '' || inp.placeholder?.includes('标签') || inp.placeholder?.includes('tag')) { const nativeSet = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; nativeSet.call(inp, tag); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true })); inp.dispatchEvent(new KeyboardEvent('keyup', { key: 'Enter', code: 'Enter', bubbles: true })); return 'FILLED'; } } return 'NO_TAG_INPUT'; }""", tag_value) logger.info(f" 标签输入结果: {tag_filled}") if tag_filled != 'FILLED': try: tag_input = self.page.locator('.p-dialog input').last tag_input.fill(tag_value) tag_input.press("Enter") except: logger.warning("⚠️ 标签输入兜底也失败") time.sleep(0.5) def save_image(self, desktop_name): """保存镜像 (完整弹窗交互)""" self.wait_for_status(desktop_name, self.STATUS["RUNNING"]) logger.info(f"🎯 尝试保存桌面 {desktop_name} 为镜像") self._click_row_action(desktop_name, "保存镜像") time.sleep(1.5) logger.info("📝 步骤1: 输入镜像名称") self.smart_fill("请输入镜像名称", f"AutoImage_{desktop_name}") time.sleep(0.5) logger.info("📝 步骤2: 选择任务类型") self._select_dropdown("任务类型", "其他") logger.info("📝 步骤3: 输入镜像标签") self._fill_tag_input("镜像标签", "auto-test") logger.info("📝 步骤4: 提交保存") self.smart_click("确定", role="button", timeout=5000) time.sleep(3)