/** * 作息报时器 · iPhone 6s(375×667)布局体检(一次性调试脚本) * * 目标:在目标设备尺寸下量化检查布局是否真的有问题,而不是凭截图猜。 * 检查项: * 1. 是否存在横向溢出(页面左右被撑破) * 2. 滚到底时,卡片操作按钮是否被底部固定 Tab 栏遮挡 * 3. 待机大屏 / 播报全屏 是否有元素超出容器宽度 * 4. 内容是否真的能滚到底(有无被裁掉、滚不到的区域) * * 运行:node dev_test_scripts/debug/debug_schedule_reminder_iphone6s_layout.js */ const fs = require('fs') const path = require('path') const express = require('express') const puppeteer = require('puppeteer') const PORT = 8980 const BASE = `http://127.0.0.1:${PORT}` const DB_FILE = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db') const DB_EXISTED_BEFORE = fs.existsSync(DB_FILE) // iPhone 6s:CSS 视口 375 × 667,DPR 2 const VIEWPORT = { width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true } const CHROME_CANDIDATES = [ 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe', 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe', 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe', ] const findChrome = () => CHROME_CANDIDATES.find(p => fs.existsSync(p)) || undefined const sleep = ms => new Promise(r => setTimeout(r, ms)) let problems = 0 // 脚本启动时已存在的日程 id(要保护的数据,清理阶段一律跳过) let PRE_EXISTING_IDS = new Set() const report = (name, ok, detail) => { if (!ok) problems += 1 console.log(` ${ok ? '✔' : '✘'} ${name}${detail ? '\n ' + detail : ''}`) } const buildServer = () => { const app = express() app.use(express.json()) const pub = path.join(process.cwd(), 'public') app.use(express.static(pub)) const { bindRoutes } = require('../../src/server/app_order_box') bindRoutes(app) return app.listen(PORT) } const cleanupDb = () => { if (DB_EXISTED_BEFORE) return ;['', '-wal', '-shm'].forEach(suffix => { const p = DB_FILE + suffix try { if (fs.existsSync(p)) fs.unlinkSync(p) } catch (_) { /* 静默 */ } }) } const run = async (page) => { console.log(`=== 布局体检:iPhone 6s ${VIEWPORT.width}×${VIEWPORT.height} ===\n`) // ⚠️ 安全底线:先快照已有日程 id,清理时只删"本次新建"的, // 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。 const existing = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json() PRE_EXISTING_IDS = new Set((existing.data || []).map(s => s.id)) if (PRE_EXISTING_IDS.size) { console.log(`(已有 ${PRE_EXISTING_IDS.size} 套日程,本次不会删除它们)\n`) } await fetch(`${BASE}/api/app_order_box/state/active-kit`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }), }) // 造一套 6 个时刻的日程,保证列表足够长(复现截图里的场景) await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: '体检日程(周三在公司)', weekdays: [3], items: [ { time: '15:00', name: '看下股票收盘' }, { time: '15:50', name: '起来走动下' }, { time: '16:29', name: '看下钉钉,有没有审批单' }, { time: '17:30', name: '看下 hermes,自动任务执行情况' }, { time: '17:50', name: '准备下班,上个厕所' }, { time: '18:30', name: '下班回家路上' }, ], }), }) await page.goto(`${BASE}/tools/web_order_box/index.html`, { waitUntil: 'networkidle2', timeout: 30000 }) await page.waitForFunction(() => { const b = document.getElementById('btnEnterSchedule') return b && !b.disabled }, { timeout: 15000 }) await page.click('#btnEnterSchedule') await sleep(4200) // ---------- 1. 横向溢出 ---------- console.log('[1] 横向溢出检查') const overflow = await page.evaluate(() => { const de = document.documentElement const offenders = [] document.querySelectorAll('#viewSchedule *').forEach(el => { const r = el.getBoundingClientRect() if (r.width === 0) return if (r.right > de.clientWidth + 0.5 || r.left < -0.5) { offenders.push({ cls: (el.className && String(el.className)) || el.tagName, left: Math.round(r.left), right: Math.round(r.right), }) } }) return { clientWidth: de.clientWidth, scrollWidth: de.scrollWidth, offenders: offenders.slice(0, 8) } }) report('页面无横向滚动', overflow.scrollWidth <= overflow.clientWidth, `clientWidth=${overflow.clientWidth} scrollWidth=${overflow.scrollWidth}`) report('无元素超出右边界', overflow.offenders.length === 0, JSON.stringify(overflow.offenders)) // ---------- 2. 大字时钟是否溢出 ---------- console.log('\n[2] 待机大屏检查') const clockInfo = await page.evaluate(() => { const clock = document.getElementById('schedClock') const panel = document.getElementById('schedStandbyPanel') const next = document.getElementById('schedNext') const cs = window.getComputedStyle(clock) return { clockFontSize: cs.fontSize, clockScrollW: clock.scrollWidth, clockClientW: clock.clientWidth, panelH: Math.round(panel.getBoundingClientRect().height), nextH: Math.round(next.getBoundingClientRect().height), nextText: next.textContent, viewportH: window.innerHeight, } }) report('时钟文字未被裁切', clockInfo.clockScrollW <= clockInfo.clockClientW + 1, `font=${clockInfo.clockFontSize} scrollW=${clockInfo.clockScrollW} clientW=${clockInfo.clockClientW}`) console.log(` 待机面板高 ${clockInfo.panelH}px / 视口高 ${clockInfo.viewportH}px(占 ${Math.round(clockInfo.panelH / clockInfo.viewportH * 100)}%)`) console.log(` "下一条"文案高 ${clockInfo.nextH}px:${clockInfo.nextText}`) // ---------- 3. 底部 Tab 栏是否遮挡内容 ---------- console.log('\n[3] 待机中状态 + 底部 Tab 栏遮挡检查') // 先点"用这套待机",复现截图里的真实状态(大屏显示"下一条…"倒计时) await page.evaluate(() => { window.scrollTo(0, 0) const b = Array.from(document.querySelectorAll('#schedulesList button')) .find(x => String(x.textContent || '').indexOf('用这套待机') >= 0) if (b) b.click() }) await sleep(1200) const nextInfo = await page.evaluate(() => { const el = document.getElementById('schedNext') const cs = window.getComputedStyle(el) const lineH = parseFloat(cs.lineHeight) || 25 const h = el.getBoundingClientRect().height return { text: el.textContent, height: Math.round(h), lines: Math.max(1, Math.round(h / lineH)), status: document.getElementById('schedStatus').textContent, } }) console.log(` "下一条"占 ${nextInfo.lines} 行:${nextInfo.text}`) console.log(` 状态栏:${nextInfo.status}`) report('"下一条"文案 2 行以内(短屏不会被撑太高)', nextInfo.lines <= 2, `实际 ${nextInfo.lines} 行`) await page.evaluate(() => { window.scrollTo(0, document.body.scrollHeight) }) await sleep(400) // 把内部滚动容器也拉到底 await page.evaluate(() => { document.querySelectorAll('.motion-body').forEach(el => { el.scrollTop = el.scrollHeight }) }) await sleep(400) const overlap = await page.evaluate(() => { const tabBar = document.getElementById('schedTabBar') const tabTop = tabBar.getBoundingClientRect().top const lastCard = document.querySelector('#schedulesList .card-item:last-child') const actions = lastCard ? lastCard.querySelector('.card-actions') : null const body = document.querySelector('#viewSchedule .motion-body') const cs = body ? window.getComputedStyle(body) : null return { tabTop: Math.round(tabTop), tabH: Math.round(tabBar.getBoundingClientRect().height), actionsBottom: actions ? Math.round(actions.getBoundingClientRect().bottom) : null, bodyPadBottom: cs ? cs.paddingBottom : null, bodyScrollTop: body ? Math.round(body.scrollTop) : null, bodyScrollH: body ? body.scrollHeight : null, bodyClientH: body ? body.clientHeight : null, docScrollTop: Math.round(window.scrollY), docScrollH: document.documentElement.scrollHeight, docClientH: document.documentElement.clientHeight, } }) console.log(' ' + JSON.stringify(overlap)) report('滚到底后,卡片操作按钮完整露出(不被 Tab 栏遮挡)', overlap.actionsBottom !== null && overlap.actionsBottom <= overlap.tabTop, `按钮底 ${overlap.actionsBottom}px vs Tab栏顶 ${overlap.tabTop}px(Tab栏高 ${overlap.tabH}px,body padding-bottom ${overlap.bodyPadBottom})`) // ---------- 4. 播报全屏检查 ---------- console.log('\n[4] 播报全屏弹窗检查') await page.evaluate(() => { const modal = document.getElementById('schedAlertModal') modal.style.display = 'flex' document.getElementById('schedAlertTime').textContent = '18:30' document.getElementById('schedAlertName').textContent = '下班回家路上' document.getElementById('schedAlertRepeat').textContent = '第 1 / 5 次播报 · 每 30 秒一次' }) await sleep(300) const alertInfo = await page.evaluate(() => { const container = document.querySelector('.sched-alert-container') const ack = document.getElementById('btnSchedAck') const time = document.getElementById('schedAlertTime') const r = ack.getBoundingClientRect() return { containerH: Math.round(container.getBoundingClientRect().height), viewportH: window.innerHeight, ackBottom: Math.round(r.bottom), ackVisible: r.bottom <= window.innerHeight + 1 && r.top >= 0, timeScrollW: time.scrollWidth, timeClientW: time.clientWidth, } }) report('「我知道了」按钮在视口内可见(不用滚动)', alertInfo.ackVisible, `按钮底 ${alertInfo.ackBottom}px / 视口高 ${alertInfo.viewportH}px,容器高 ${alertInfo.containerH}px`) report('播报时刻大字未被裁切', alertInfo.timeScrollW <= alertInfo.timeClientW + 1, `scrollW=${alertInfo.timeScrollW} clientW=${alertInfo.timeClientW}`) // ---------- 5. 编辑器检查 ---------- console.log('\n[5] 日程编辑器检查') await page.evaluate(() => { document.getElementById('schedAlertModal').style.display = 'none' window.scrollTo(0, 0) document.getElementById('btnNewSchedule').click() }) await sleep(500) const editorOpened = await page.evaluate(() => window.getComputedStyle(document.getElementById('schedEditorModal')).display !== 'none') report('编辑器已打开(测试前置条件)', editorOpened) const editorInfo = await page.evaluate(() => { const sheet = document.querySelector('#schedEditorModal .modal-sheet') const saveBtn = document.getElementById('btnSchedEditorSave') const bodyEl = document.getElementById('schedEditorBody') const chips = Array.from(document.querySelectorAll('.weekday-chip')) const rows = new Set(chips.map(c => Math.round(c.getBoundingClientRect().top))) // 关键:带 √ 的 chip 文字是否比 chip 自身还宽(会被 nowrap 撑破、压到邻居) const chipDetails = chips.map(c => { const r = c.getBoundingClientRect() return { text: String(c.textContent || '').trim(), boxW: Math.round(r.width), // scrollWidth > clientWidth 说明内容被裁/溢出 overflow: c.scrollWidth - c.clientWidth, } }) return { sheetH: Math.round(sheet.getBoundingClientRect().height), viewportH: window.innerHeight, weekdayRows: rows.size, chipDetails: chipDetails, overflowChips: chipDetails.filter(c => c.overflow > 1), saveBtnBottom: saveBtn ? Math.round(saveBtn.getBoundingClientRect().bottom) : null, bodyScrollH: bodyEl.scrollHeight, bodyClientH: bodyEl.clientHeight, docScrollW: document.documentElement.scrollWidth, docClientW: document.documentElement.clientWidth, } }) report('星期 chip 单行显示', editorInfo.weekdayRows === 1, `实际占用 ${editorInfo.weekdayRows} 行`) report('星期 chip 文字未溢出自身边框(带 √ 也放得下)', editorInfo.overflowChips.length === 0, JSON.stringify(editorInfo.chipDetails.map(c => c.text + '=' + c.boxW + 'px,溢出' + c.overflow))) report('编辑器打开后页面仍无横向滚动', editorInfo.docScrollW <= editorInfo.docClientW, `scrollWidth=${editorInfo.docScrollW} clientWidth=${editorInfo.docClientW}`) console.log(` 编辑器面板高 ${editorInfo.sheetH}px(视口 ${editorInfo.viewportH}px),内容可滚动 ${editorInfo.bodyScrollH} > ${editorInfo.bodyClientH}`) await page.evaluate(() => { document.getElementById('schedEditorModal').style.display = 'none' }) // ---------- 6. 清理(只删本脚本新建的,绝不碰已有数据) ---------- const list = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json() let removed = 0 for (const s of (list.data || [])) { if (PRE_EXISTING_IDS.has(s.id)) continue await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules?id=${encodeURIComponent(s.id)}`, { method: 'DELETE' }) removed += 1 } console.log(`\n[6] 清理:删除本次新建的 ${removed} 套日程,保留原有 ${PRE_EXISTING_IDS.size} 套`) await fetch(`${BASE}/api/app_order_box/state/active-kit`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }), }) console.log(`\n=== 体检完成:发现 ${problems} 处问题 ===`) } ;(async () => { const server = buildServer() await sleep(500) const browser = await puppeteer.launch({ headless: true, executablePath: findChrome(), args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio'], }) const page = await browser.newPage() await page.setViewport(VIEWPORT) const pageErrors = [] page.on('pageerror', e => pageErrors.push(String(e && e.message || e))) await page.evaluateOnNewDocument(() => { try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ } }) try { await run(page) } catch (e) { console.log('\n✘ 体检异常:' + String((e && e.stack) || e)) problems += 1 } if (pageErrors.length) console.log('\n页面报错:\n ' + pageErrors.join('\n ')) await browser.close() server.close(() => { cleanupDb(); process.exit(0) }) })()