a3e8911deb
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
320 lines
15 KiB
JavaScript
320 lines
15 KiB
JavaScript
/**
|
||
* 作息报时器 · 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) })
|
||
})() |