feat: 新增作息报时器功能并替换短链接前缀为/to/

- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能
- 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题
- 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链
- 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义
- 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接
- 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
yangxiangyuan
2026-10-09 14:04:37 +08:00
parent ac475b6659
commit a3e8911deb
131 changed files with 7695 additions and 60 deletions
@@ -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) })
})()