""" H5 原型页面批量截图脚本 iPhone 15 Pro Max: 430×932, DPR:3 → 输出 1290×N 像素 PNG """ import asyncio import sys from pathlib import Path from playwright.async_api import async_playwright, Page BASE_URL = "http://127.0.0.1:5500/docs/h5_ui/pages" OUT_DIR = Path(__file__).resolve().parents[2] / "docs" / "h5_ui" / "screenshots" # 确保输出目录存在 OUT_DIR.mkdir(parents=True, exist_ok=True) # 21 个默认态页面 DEFAULT_PAGES = [ "login", "apply", "reviewing", "no-permission", "task-list", "task-detail", "task-detail-priority", "task-detail-relationship", "task-detail-callback", "performance", "performance-records", "board-finance", "board-customer", "board-coach", "coach-detail", "customer-detail", "customer-service-records", "chat", "chat-history", "notes", "my-profile", ] HIDE_SCROLLBAR_JS = """ () => { document.documentElement.style.overflow = 'auto'; document.documentElement.style.scrollbarWidth = 'none'; const s = document.createElement('style'); s.textContent = '::-webkit-scrollbar { display: none !important; }'; document.head.appendChild(s); } """ async def setup_page(page: Page, url: str) -> None: """导航到页面,等待 Tailwind CDN 渲染,隐藏滚动条""" # Go Live 的 WebSocket 长连接会阻止 networkidle,改用 load await page.goto(url, wait_until="load", timeout=15000) await page.wait_for_timeout(2500) # Tailwind CDN JIT 渲染 await page.evaluate(HIDE_SCROLLBAR_JS) await page.wait_for_timeout(300) async def take_screenshot(page: Page, name: str) -> dict: """截取全页截图并返回文件信息""" out_path = OUT_DIR / f"{name}.png" await page.screenshot(path=str(out_path), full_page=True) size = out_path.stat().st_size return {"name": name, "size": size, "path": str(out_path)} async def get_page_functions(page: Page) -> list[str]: """获取页面内联 script 中定义的函数名""" return await page.evaluate(""" () => { const scripts = document.querySelectorAll('script:not([src])'); const fns = []; for (const s of scripts) { const matches = s.textContent.matchAll(/function\\s+(\\w+)/g); for (const m of matches) fns.push(m[1]); } return fns; } """) async def screenshot_default_pages(page: Page) -> list[dict]: """截取所有默认态页面""" results = [] for name in DEFAULT_PAGES: url = f"{BASE_URL}/{name}.html" await setup_page(page, url) info = await take_screenshot(page, name) print(f" ✅ {name} ({info['size']:,} bytes)") results.append(info) return results async def screenshot_interactive_pages(page: Page) -> list[dict]: """截取所有交互态页面 — 全部用 DOM class 操作,不依赖闭包内函数""" results = [] # --- task-list: context menu --- await setup_page(page, f"{BASE_URL}/task-list.html") # showContextMenu 在 IIFE 闭包内,改用 DOM class 操作 await page.evaluate(""" () => { const overlay = document.getElementById('contextOverlay'); const menu = document.getElementById('contextMenu'); if (overlay) overlay.classList.add('active'); if (menu) { menu.style.left = '120px'; menu.style.top = '300px'; menu.classList.add('active'); } } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "task-list--context-menu") print(f" ✅ task-list--context-menu ({info['size']:,} bytes)") results.append(info) # --- task-list: note modal --- await setup_page(page, f"{BASE_URL}/task-list.html") # remarkModal 通过 .active class 显示 await page.evaluate(""" () => { const modal = document.getElementById('remarkModal'); if (modal) modal.classList.add('active'); } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "task-list--note-modal") print(f" ✅ task-list--note-modal ({info['size']:,} bytes)") results.append(info) # --- task-detail: note modal --- await setup_page(page, f"{BASE_URL}/task-detail.html") await page.evaluate(""" () => { const modal = document.getElementById('noteModal'); if (modal) { modal.classList.add('active'); modal.classList.remove('hidden'); modal.style.display = ''; } } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "task-detail--note-modal") print(f" ✅ task-detail--note-modal ({info['size']:,} bytes)") results.append(info) # --- task-detail: abandon modal --- await setup_page(page, f"{BASE_URL}/task-detail.html") await page.evaluate(""" () => { const modal = document.getElementById('abandonModal'); if (modal) { modal.classList.add('active'); modal.classList.remove('hidden'); modal.style.display = ''; } } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "task-detail--abandon-modal") print(f" ✅ task-detail--abandon-modal ({info['size']:,} bytes)") results.append(info) # --- coach-detail: note modal --- await setup_page(page, f"{BASE_URL}/coach-detail.html") await page.evaluate(""" () => { const popup = document.getElementById('notesPopup'); if (popup) { popup.classList.add('active', 'show'); popup.classList.remove('hidden'); popup.style.display = ''; } } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "coach-detail--note-modal") print(f" ✅ coach-detail--note-modal ({info['size']:,} bytes)") results.append(info) return results async def screenshot_board_finance_interactive(page: Page) -> list[dict]: """board-finance 交互态截图 — 用 DOM class 操作""" results = [] # filter dropdown (时间筛选) await setup_page(page, f"{BASE_URL}/board-finance.html") await page.evaluate(""" () => { const overlay = document.getElementById('filterOverlay'); const dropdown = document.getElementById('timeDropdown'); const bar = document.getElementById('filterBar'); if (overlay) overlay.classList.add('show'); if (dropdown) { // 定位到筛选栏下方 if (bar) { const rect = bar.getBoundingClientRect(); dropdown.style.top = rect.bottom + 'px'; } dropdown.classList.add('show'); } } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "board-finance--filter-dropdown") print(f" ✅ board-finance--filter-dropdown ({info['size']:,} bytes)") results.append(info) # tip modal await setup_page(page, f"{BASE_URL}/board-finance.html") await page.evaluate(""" () => { const toast = document.getElementById('tipToast'); const overlay = document.getElementById('tipOverlay'); const titleEl = document.getElementById('tipTitle'); const contentEl = document.getElementById('tipContent'); if (titleEl) titleEl.textContent = '发生额/正价'; if (contentEl) contentEl.innerHTML = '发生额:所有订单的原价总和(不扣优惠)\\n用于衡量门店整体业务规模'; if (overlay) overlay.classList.add('show'); if (toast) toast.classList.add('show'); } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "board-finance--tip-modal") print(f" ✅ board-finance--tip-modal ({info['size']:,} bytes)") results.append(info) # toc panel await setup_page(page, f"{BASE_URL}/board-finance.html") await page.evaluate(""" () => { const dropdown = document.getElementById('tocDropdown'); const overlay = document.getElementById('tocOverlay'); if (dropdown) dropdown.classList.add('show'); if (overlay) overlay.classList.add('show'); } """) await page.wait_for_timeout(500) info = await take_screenshot(page, "board-finance--toc-panel") print(f" ✅ board-finance--toc-panel ({info['size']:,} bytes)") results.append(info) return results async def screenshot_board_coach_interactive(page: Page) -> list[dict]: """board-coach 交互态截图 — 用 DOM class 操作切换维度容器""" results = [] # 4 个排序维度切换:直接操作 dim-container 的 active class sort_dims = [ ("perf-high", "dim-perf", "定档业绩最高"), ("salary-high", "dim-salary", "工资最高"), ("storage-value", "dim-sv", "客源储值最高"), ("task-complete", "dim-task", "任务完成最多"), ] for suffix, dim_id, label_text in sort_dims: await setup_page(page, f"{BASE_URL}/board-coach.html") await page.evaluate(f""" () => {{ // 切换维度容器 document.querySelectorAll('.dim-container').forEach(el => el.classList.remove('active')); const target = document.getElementById('{dim_id}'); if (target) target.classList.add('active'); // 更新排序标签 const label = document.getElementById('sortLabel'); if (label) label.textContent = '{label_text}'; }} """) await page.wait_for_timeout(500) info = await take_screenshot(page, f"board-coach--{suffix}") print(f" ✅ board-coach--{suffix} ({info['size']:,} bytes)") results.append(info) # 3 个筛选下拉:直接操作 dropdown 的 show class filter_types = [ ("sort-dropdown", "sortDropdown"), ("skill-dropdown", "skillDropdown"), ("time-dropdown", "timeDropdown"), ] for suffix, dropdown_id in filter_types: await setup_page(page, f"{BASE_URL}/board-coach.html") await page.evaluate(f""" () => {{ const overlay = document.getElementById('filterOverlay'); const dropdown = document.getElementById('{dropdown_id}'); const bar = document.getElementById('filterBar'); if (overlay) overlay.classList.add('show'); if (dropdown) {{ // 定位到筛选栏下方 if (bar) {{ const rect = bar.getBoundingClientRect(); dropdown.style.top = rect.bottom + 'px'; }} dropdown.classList.add('show'); }} }} """) await page.wait_for_timeout(500) info = await take_screenshot(page, f"board-coach--{suffix}") print(f" ✅ board-coach--{suffix} ({info['size']:,} bytes)") results.append(info) return results async def screenshot_board_customer_interactive(page: Page) -> list[dict]: """board-customer 交互态截图 — 用 DOM class 操作切换维度容器""" results = [] # 8 个维度切换:直接操作 dim-container 的 active class customer_dims = [ ("recall", "dim-recall", "最应召回"), ("potential", "dim-potential", "最大消费潜力"), ("balance", "dim-balance", "最高余额"), ("recharge", "dim-recharge", "最近充值"), ("recent", "dim-recent", "最近到店"), ("spend60", "dim-spend60", "最高消费 近60天"), ("freq60", "dim-freq60", "最频繁 近60天"), ("loyal", "dim-loyal", "最专一 近60天"), ] for suffix, dim_id, label_text in customer_dims: await setup_page(page, f"{BASE_URL}/board-customer.html") await page.evaluate(f""" () => {{ // 切换维度容器 document.querySelectorAll('.dim-container').forEach(el => el.classList.remove('active')); const target = document.getElementById('{dim_id}'); if (target) target.classList.add('active'); // 更新类型标签 const label = document.getElementById('typeLabel'); if (label) label.textContent = '{label_text}'; }} """) await page.wait_for_timeout(500) info = await take_screenshot(page, f"board-customer--{suffix}") print(f" ✅ board-customer--{suffix} ({info['size']:,} bytes)") results.append(info) # 2 个筛选下拉:直接操作 dropdown 的 show class filter_types = [ ("type-dropdown", "typeDropdown"), ("project-dropdown", "projectDropdown"), ] for suffix, dropdown_id in filter_types: await setup_page(page, f"{BASE_URL}/board-customer.html") await page.evaluate(f""" () => {{ const overlay = document.getElementById('filterOverlay'); const dropdown = document.getElementById('{dropdown_id}'); const bar = document.getElementById('filterBar'); if (overlay) overlay.classList.add('show'); if (dropdown) {{ if (bar) {{ const rect = bar.getBoundingClientRect(); dropdown.style.top = rect.bottom + 'px'; }} dropdown.classList.add('show'); }} }} """) await page.wait_for_timeout(500) info = await take_screenshot(page, f"board-customer--{suffix}") print(f" ✅ board-customer--{suffix} ({info['size']:,} bytes)") results.append(info) return results async def main(): print("=" * 60) print("H5 截图 — iPhone 15 Pro Max (430×932, DPR:3)") print(f"输出目录: {OUT_DIR}") print("=" * 60) async with async_playwright() as p: # --hide-scrollbars 消除桌面模式下滚动条占宽,保证输出 1290px (430×3) browser = await p.chromium.launch( headless=False, args=["--hide-scrollbars"], ) context = await browser.new_context( viewport={"width": 430, "height": 932}, device_scale_factor=3, ) page = await context.new_page() # 验证 DPR dpr = await page.evaluate("() => window.devicePixelRatio") print(f"\ndevicePixelRatio = {dpr}") assert dpr == 3, f"DPR 应为 3,实际为 {dpr}" # 1. 默认态截图 print("\n📸 默认态截图 (21 页):") default_results = await screenshot_default_pages(page) # 2. 交互态截图 print("\n📸 交互态截图:") interactive_results = await screenshot_interactive_pages(page) # 3. board-finance 交互态 print("\n📸 board-finance 交互态:") finance_results = await screenshot_board_finance_interactive(page) # 4. board-coach 交互态 print("\n📸 board-coach 交互态:") coach_results = await screenshot_board_coach_interactive(page) # 5. board-customer 交互态 print("\n📸 board-customer 交互态:") customer_results = await screenshot_board_customer_interactive(page) all_results = ( default_results + interactive_results + finance_results + coach_results + customer_results ) await browser.close() # 验证第一张截图的尺寸(读 PNG IHDR chunk,无需 Pillow) import struct with open(all_results[0]["path"], "rb") as f: f.read(16) # 跳过 PNG 签名(8) + IHDR chunk length(4) + type(4) w, h = struct.unpack(">II", f.read(8)) print(f"\n🔍 验证: {all_results[0]['name']}.png → {w}×{h} px") assert w == 1290, f"宽度应为 1290 (430×3),实际为 {w}" print(f"\n✅ 全部完成: {len(all_results)} 张截图") print(f" 输出目录: {OUT_DIR}") if __name__ == "__main__": asyncio.run(main())