437 lines
16 KiB
Python
437 lines
16 KiB
Python
"""
|
||
[已废弃 — 本 spec 不使用]
|
||
|
||
H5 原型页面批量截图脚本(全页面截图,430×932 视口)。
|
||
已被 anchor_compare.py 的逐段截图方案(430×752 视口)取代。
|
||
|
||
保留原因:仍可用于快速获取全页面参考截图,但不作为像素对比的输入源。
|
||
替代工具:scripts/ops/anchor_compare.py extract-h5 <page>
|
||
|
||
原始参数: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 = '<strong>发生额</strong>:所有订单的原价总和(不扣优惠)\\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())
|