feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* 作息报时器 · 老 Safari CSS 兼容性复现与验证(一次性调试脚本)
|
||||
*
|
||||
* 背景:iPhone 6s 上反馈两个现象:
|
||||
* 1. 待机大屏的时间没有大字体
|
||||
* 2. 到点循环播报了 5 次,但屏幕上没有「我知道了」按钮可点
|
||||
*
|
||||
* 假设:该机 Safari 版本较老,不支持 clamp() / dvh / inset 简写。
|
||||
* 浏览器遇到「不支持的属性值」时会**只丢弃那一条声明**,同一条规则里的其它声明照常生效。
|
||||
* → 于是 .standby-clock 的 font-size 被丢弃(字体退回默认 16px,但金色/粗体仍在)
|
||||
* → .modal-overlay 的 inset:0 被丢弃(遮罩没有定位到四边,跑到文档静态位置,即视口外)
|
||||
*
|
||||
* 本脚本用请求拦截把这三处改成「浏览器不认识的值」,等同于老 Safari 的行为,
|
||||
* 以此复现现象、验证因果;修完后同脚本应转为全部通过。
|
||||
*
|
||||
* 运行:node dev_test_scripts/debug/debug_schedule_reminder_legacy_safari.js
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const express = require('express')
|
||||
const puppeteer = require('puppeteer')
|
||||
|
||||
const PORT = 8981
|
||||
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)
|
||||
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))
|
||||
|
||||
// ⚠️ 安全底线:主流程开始前快照已有日程 id,清理时只删"本次新建"的,
|
||||
// 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。
|
||||
let PRE_EXISTING_IDS = new Set()
|
||||
|
||||
/**
|
||||
* 把"老 Safari 不支持的写法"替换成浏览器不认识的值 → 该声明被丢弃。
|
||||
* 这是对老浏览器行为的高保真模拟:只丢那一条,同规则其它声明不受影响。
|
||||
*/
|
||||
const degradeForLegacySafari = css => String(css)
|
||||
.replace(/\bclamp\(/g, 'clampx(') // 不支持 clamp() → 整条 font-size 被丢弃
|
||||
.replace(/\b(\d+)dvh\b/g, '$1dvhx') // 不支持 dvh 单位 → 整条 height 被丢弃
|
||||
.replace(/inset:\s*0;/g, 'inset-legacy: 0;') // 不支持 inset 简写 → 整条被丢弃
|
||||
|
||||
const beijingHHmm = () => new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit',
|
||||
}).format(new Date())
|
||||
|
||||
const buildServer = () => {
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
app.use(express.static(path.join(process.cwd(), 'public')))
|
||||
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 probe = async (browser, legacy) => {
|
||||
const page = await browser.newPage()
|
||||
await page.setViewport(VIEWPORT)
|
||||
await page.setCacheEnabled(false)
|
||||
const pageErrors = []
|
||||
page.on('pageerror', e => pageErrors.push(String(e && e.message || e)))
|
||||
|
||||
await page.setRequestInterception(true)
|
||||
page.on('request', async req => {
|
||||
const url = req.url()
|
||||
if (legacy && url.indexOf('order_box.css') >= 0) {
|
||||
try {
|
||||
const css = await (await fetch(url)).text()
|
||||
return req.respond({ status: 200, contentType: 'text/css; charset=utf-8', body: degradeForLegacySafari(css) })
|
||||
} catch (e) { return req.continue() }
|
||||
}
|
||||
return req.continue()
|
||||
})
|
||||
|
||||
// 准备数据:当前分钟到点的日程
|
||||
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }),
|
||||
})
|
||||
const targetTime = beijingHHmm()
|
||||
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: '兼容性验证日程', weekdays: [1, 2, 3, 4, 5, 6, 7],
|
||||
items: [{ time: targetTime, name: '验证事项', repeatTimes: 5, intervalSec: 30 }],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.evaluateOnNewDocument(() => {
|
||||
try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ }
|
||||
})
|
||||
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(4000)
|
||||
await page.evaluate(() => {
|
||||
const modal = document.getElementById('dialogOverlay')
|
||||
if (modal && window.getComputedStyle(modal).display !== 'none') {
|
||||
const b = Array.from(modal.querySelectorAll('button')).find(x => /听到了|知道了|确定/.test(x.textContent || ''))
|
||||
if (b) b.click()
|
||||
}
|
||||
})
|
||||
await sleep(300)
|
||||
|
||||
// 进入待机 → 触发到点播报
|
||||
await page.evaluate(() => { window.scrollTo(0, 0) })
|
||||
await page.evaluate(() => {
|
||||
const b = Array.from(document.querySelectorAll('#schedulesList button'))
|
||||
.find(x => String(x.textContent || '').indexOf('用这套待机') >= 0)
|
||||
if (b) b.click()
|
||||
})
|
||||
|
||||
// 等播报弹窗出现
|
||||
let alertAppeared = false
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
alertAppeared = await page.evaluate(() =>
|
||||
window.getComputedStyle(document.getElementById('schedAlertModal')).display !== 'none')
|
||||
if (alertAppeared) break
|
||||
await sleep(500)
|
||||
}
|
||||
await sleep(600)
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const clock = document.getElementById('schedClock')
|
||||
const modal = document.getElementById('schedAlertModal')
|
||||
const ack = document.getElementById('btnSchedAck')
|
||||
const overlay = window.getComputedStyle(modal)
|
||||
const modalRect = modal.getBoundingClientRect()
|
||||
const ackRect = ack.getBoundingClientRect()
|
||||
const vh = window.innerHeight
|
||||
const vw = window.innerWidth
|
||||
// 遮罩是否真的覆盖了视口(老 Safari 丢 inset 后,遮罩会停在文档静态位置、跑出视口)
|
||||
const modalOverlapsViewport = modalRect.bottom > 0 && modalRect.top < vh &&
|
||||
modalRect.right > 0 && modalRect.left < vw
|
||||
const ackVisible = ackRect.top >= 0 && ackRect.bottom <= vh && ackRect.width > 0
|
||||
return {
|
||||
clockFontSize: window.getComputedStyle(clock).fontSize,
|
||||
clockText: clock.textContent,
|
||||
modalDisplay: overlay.display,
|
||||
modalRect: { top: Math.round(modalRect.top), bottom: Math.round(modalRect.bottom), h: Math.round(modalRect.height) },
|
||||
ackRect: { top: Math.round(ackRect.top), bottom: Math.round(ackRect.bottom), w: Math.round(ackRect.width) },
|
||||
viewport: { vw, vh },
|
||||
docScrollH: document.documentElement.scrollHeight,
|
||||
modalOverlapsViewport, ackVisible,
|
||||
}
|
||||
})
|
||||
|
||||
// 清理
|
||||
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/logs`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ itemId: 'x', itemName: 'x', status: 'fired' }),
|
||||
}).catch(() => {})
|
||||
// 清理:只删本次新建的日程,绝不删除数据库里原本就存在的用户数据
|
||||
const list = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
|
||||
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' })
|
||||
}
|
||||
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kitId: '' }),
|
||||
})
|
||||
await page.close()
|
||||
return { result, pageErrors, targetTime }
|
||||
}
|
||||
|
||||
;(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 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))
|
||||
|
||||
console.log('=== 老 Safari CSS 兼容性复现 / 验证 ===')
|
||||
if (PRE_EXISTING_IDS.size) {
|
||||
console.log(`(检测到已有 ${PRE_EXISTING_IDS.size} 套日程,本次不会删除它们)`)
|
||||
}
|
||||
console.log('')
|
||||
|
||||
console.log('[A] 模拟老 Safari(clamp / dvh / inset 均不被支持)')
|
||||
const legacy = await probe(browser, true)
|
||||
console.log(` 目标时刻 ${legacy.targetTime}`)
|
||||
console.log(` 待机时钟字号 = ${legacy.result.clockFontSize}(文字 ${legacy.result.clockText})`)
|
||||
console.log(` 播报弹窗 display=${legacy.result.modalDisplay},位置 ${JSON.stringify(legacy.result.modalRect)},视口高 ${legacy.result.viewport.vh}`)
|
||||
console.log(` 「我知道了」按钮 ${JSON.stringify(legacy.result.ackRect)}`)
|
||||
console.log(` 弹窗是否落在视口内 = ${legacy.result.modalOverlapsViewport},按钮是否可见 = ${legacy.result.ackVisible}`)
|
||||
const clockSmall = parseFloat(legacy.result.clockFontSize) < 30
|
||||
console.log(`\n → 现象1(时间没有大字体)${clockSmall ? '已复现 ✔' : '未复现 ✘'}`)
|
||||
console.log(` → 现象2(没有「我知道了」按钮)${(!legacy.result.ackVisible || !legacy.result.modalOverlapsViewport) ? '已复现 ✔' : '未复现 ✘'}`)
|
||||
|
||||
console.log('\n[B] 正常模式(现代浏览器,不降级)')
|
||||
const modern = await probe(browser, false)
|
||||
console.log(` 待机时钟字号 = ${modern.result.clockFontSize}`)
|
||||
console.log(` 弹窗落视口内 = ${modern.result.modalOverlapsViewport},按钮可见 = ${modern.result.ackVisible}`)
|
||||
console.log(` → 与 iPhone 18 等新机表现一致:${parseFloat(modern.result.clockFontSize) >= 30 && modern.result.ackVisible ? '正常 ✔' : '异常 ✘'}`)
|
||||
|
||||
if (legacy.pageErrors.length || modern.pageErrors.length) {
|
||||
console.log('\n页面报错:')
|
||||
;[...legacy.pageErrors, ...modern.pageErrors].slice(0, 10).forEach(t => console.log(' ! ' + t))
|
||||
}
|
||||
|
||||
console.log('\n判定标准:修完后,[A] 段的时钟字号应 >= 30px,且「我知道了」按钮应在视口内可见。')
|
||||
console.log(`当前 [A] 是否已修复:${!clockSmall && legacy.result.ackVisible && legacy.result.modalOverlapsViewport ? '是 ✔' : '否 ✘(仍需修复)'}`)
|
||||
|
||||
await browser.close()
|
||||
server.close(() => { cleanupDb(); process.exit(0) })
|
||||
})()
|
||||
Reference in New Issue
Block a user