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,355 @@
/**
* 作息报时器(schedule_reminder)前端端到端验证(一次性调试脚本)
*
* 做法:
* 1. 在 8979 起一个精简 express(静态 public + app_order_box API),不碰 8976 主服务;
* 2. 用项目自带的 puppeteer 打开 web_order_box 页面,走完整用户链路:
* 进入报时器 → 新建日程(时刻设为"当前北京时间分钟",使其立刻到点)→ 保存 →
* 用这套待机 → 校验到点播报弹窗 + 播报记录 → 点「我知道了」→ 校验记录变"已确认";
* 3. 收集页面 pageerror / console.error,最后打印结果。
*
* 运行:node dev_test_scripts/debug/debug_schedule_reminder_frontend.js
*/
const fs = require('fs')
const path = require('path')
const express = require('express')
const puppeteer = require('puppeteer')
const PORT = 8979
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 sleep = ms => new Promise(r => setTimeout(r, ms))
// 本机未下载 puppeteer 自带 Chrome,改用系统已安装的 Chrome / Edge
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 beijingHHmm = (offsetMs) => new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai', hour12: false, hour: '2-digit', minute: '2-digit',
}).format(new Date(Date.now() + (offsetMs || 0)))
let passed = 0
let failed = 0
const check = (name, ok, extra) => {
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
else { failed += 1; console.log(` ✘ ${name}${extra ? ' → ' + extra : ''}`) }
}
const buildServer = () => {
const app = express()
app.use(express.json())
const pub = path.join(process.cwd(), 'public')
app.use(express.static(pub))
app.get('/tools/web_order_box', (req, res) => res.sendFile(path.join(pub, 'tools', 'web_order_box', 'index.html')))
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 (_) { /* 静默 */ }
})
console.log('(测试库为本次新建,已清理:' + path.basename(DB_FILE) + ')')
}
const clickByText = (page, selector, text) => page.evaluate((sel, txt) => {
const btns = Array.from(document.querySelectorAll(sel))
const target = btns.find(b => String(b.textContent || '').indexOf(txt) >= 0)
if (target) { target.click(); return true }
return false
}, selector, text)
const visible = (page, selector) => page.evaluate(sel => {
const el = document.querySelector(sel)
if (!el) return false
const st = window.getComputedStyle(el)
return st.display !== 'none' && st.visibility !== 'hidden'
}, selector)
const textOf = (page, selector) => page.evaluate(sel => {
const el = document.querySelector(sel)
return el ? String(el.textContent || '').trim() : ''
}, selector)
/** 模拟用户点掉浮层(语音检查弹窗 / 交互详情弹窗),避免其遮挡后续点击 */
const dismissOverlays = async (page) => {
await page.evaluate(() => {
const pick = (id, re) => {
const root = document.getElementById(id)
if (!root || window.getComputedStyle(root).display === 'none') return
const b = Array.from(root.querySelectorAll('button')).find(x => re.test(String(x.textContent || '')))
if (b) b.click()
}
pick('dialogOverlay', /听到了|知道了|确定|取消|没听到/)
pick('interactionOverlay', /关闭/)
})
await sleep(250)
}
const run = async (page) => {
console.log('=== 作息报时器 · 前端端到端验证 ===\n')
// ---------- 1. 进入报时器 ----------
console.log('[1] 进入作息报时器')
// 先确保框子停在 Hub(否则 activeKit 残留会让页面自动进入 kit,Hub 卡片不可点)
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kitId: '' }),
})
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 })
// ⚠️ 安全底线:快照已有日程 id,清理阶段只删本次新建的,
// 绝不删除数据库里原本就存在的用户数据(曾因无差别删除误删过用户真实日程)。
const existingRes = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
const existingIds = (existingRes.data || []).map(s => s.id)
await page.evaluate(ids => { window.__PRE_EXISTING_IDS = ids }, existingIds)
if (existingIds.length) console.log(`(检测到已有 ${existingIds.length} 套日程,本次不会删除它们)\n`)
check('Hub 上作息报时器卡片已启用', true)
await page.click('#btnEnterSchedule')
await sleep(600)
check('切换到报时器视图', await visible(page, '#viewSchedule'))
await sleep(3600) // 等交互详情弹窗(normal 2s / debug 3s)收起
await dismissOverlays(page)
await dismissOverlays(page)
const clock = await textOf(page, '#schedClock')
check('大字时钟已按 HH:mm:ss 走字', /^\d{2}:\d{2}:\d{2}$/.test(clock), clock)
const dateText = await textOf(page, '#schedDate')
check('日期为北京时间文案', dateText.indexOf('星期') >= 0, dateText)
// ---------- 2. 新建日程 ----------
console.log('\n[2] 新建作息日程(时刻设为当前分钟,便于立刻到点)')
await page.click('#btnNewSchedule')
await sleep(400)
check('编辑器已打开', await visible(page, '#schedEditorModal'))
// 生效星期 chip:默认全选 → 每个都带 √;取消勾选 √ 消失;chip 不换行
const readWeekdayChips = () => page.$$eval('.weekday-chip', els => els.map(e => ({
text: String(e.textContent || '').trim(),
active: e.classList.contains('active'),
height: e.offsetHeight,
})))
const clickWeekday = (w) => page.evaluate(n => {
const b = document.querySelector('.weekday-chip[data-weekday="' + n + '"]')
if (b) b.click()
}, w)
let chips = await readWeekdayChips()
check('星期 chip 共 7 个', chips.length === 7, chips.length)
check('默认全选:7 个都带 √', chips.every(c => c.text.indexOf('√') === 0), JSON.stringify(chips.map(c => c.text)))
check('星期 chip 未换行(高度一致)', new Set(chips.map(c => c.height)).size === 1, chips.map(c => c.height).join(','))
await clickWeekday(3)
await sleep(200)
chips = await readWeekdayChips()
let wed = chips.find(c => c.text.indexOf('周三') >= 0)
check('取消周三后不再带 √ 且不为选中态', !!wed && wed.text.indexOf('√') < 0 && !wed.active, JSON.stringify(wed))
await clickWeekday(3)
await sleep(200)
chips = await readWeekdayChips()
wed = chips.find(c => c.text.indexOf('周三') >= 0)
check('重新勾选周三后恢复 √ 与选中态', !!wed && wed.text.indexOf('√') === 0 && wed.active, JSON.stringify(wed))
// 目标时刻取"当前北京时间分钟":启动待机时可立即判定为到点(60 秒内 < 90 秒错过阈值)
const targetTime = beijingHHmm()
console.log(` 目标时刻 = ${targetTime}`)
await page.type('#schedEditorTitleInput', '自动化验证日程')
await page.$eval('.sched-item-editor input[data-field="time"]', (el, v) => {
el.value = v
el.dispatchEvent(new Event('input', { bubbles: true }))
}, targetTime)
await page.$eval('.sched-item-editor input[data-field="name"]', (el) => {
el.value = '验证事项'
el.dispatchEvent(new Event('input', { bubbles: true }))
})
await page.$eval('.sched-item-editor input[data-field="repeatTimes"]', (el) => {
el.value = '5'
el.dispatchEvent(new Event('input', { bubbles: true }))
})
// 间隔压到最小值 5 秒,便于在测试里观察到"自动复播"
await page.$eval('.sched-item-editor input[data-field="intervalSec"]', (el) => {
el.value = '5'
el.dispatchEvent(new Event('input', { bubbles: true }))
})
const hintText = await textOf(page, '.sched-item-editor .sched-item-hint')
check('编辑器实时预览播报文案', hintText.indexOf('现在是') >= 0 && hintText.indexOf('验证事项') >= 0, hintText)
const statText = await textOf(page, '#schedEditorStats')
check('编辑器统计已渲染', statText.indexOf('生效星期') >= 0, statText)
await clickByText(page, '#schedEditorBody button', '保存日程')
await sleep(3200) // 等交互详情弹窗收起 + 列表刷新
await dismissOverlays(page)
check('编辑器已关闭', !(await visible(page, '#schedEditorModal')))
const listText = await textOf(page, '#schedulesList')
check('列表出现新日程', listText.indexOf('自动化验证日程') >= 0, listText.slice(0, 80))
check('列表展示时刻预览', listText.indexOf(targetTime) >= 0 && listText.indexOf('验证事项') >= 0)
// ---------- 3. 开始待机并验证到点播报 ----------
console.log('\n[3] 用这套待机 → 验证到点播报')
await clickByText(page, '#schedulesList button', '用这套待机')
await sleep(800)
const statusText = await textOf(page, '#schedStatus')
check('状态进入待机中', statusText.indexOf('待机中') >= 0, statusText)
await page.waitForSelector('#schedAlertModal', { visible: true, timeout: 8000 }).then(
() => check('到点触发全屏播报弹窗', true),
() => check('到点触发全屏播报弹窗', false, '弹窗未出现')
)
const alertName = await textOf(page, '#schedAlertName')
const alertTime = await textOf(page, '#schedAlertTime')
const alertRepeat = await textOf(page, '#schedAlertRepeat')
check('播报弹窗显示事项名', alertName === '验证事项', alertName)
check('播报弹窗显示时刻', alertTime === targetTime, alertTime)
check('播报弹窗显示第 1/5 次', alertRepeat.indexOf('第 1 / 5 次') >= 0, alertRepeat)
// ---------- 4. 自动重复播报 + 我知道了 ----------
console.log('\n[4] 验证按间隔自动复播,再点「我知道了」提前结束本条')
await sleep(6500) // 间隔 5 秒,等第 2 次自动播报
const repeat2 = await textOf(page, '#schedAlertRepeat')
check('到点后按间隔自动复播(第 2 次)', repeat2.indexOf('第 2 / 5 次') >= 0, repeat2)
await page.click('#btnSchedAck')
await sleep(600)
check('点「我知道了」后弹窗关闭', !(await visible(page, '#schedAlertModal')))
await sleep(6500) // 再等一个完整间隔,确认已彻底停止
check('确认后不再继续复播', !(await visible(page, '#schedAlertModal')))
// ---------- 5. 播报记录 ----------
console.log('\n[5] 播报记录')
await clickByText(page, '#schedTabBar button', '记录')
await sleep(3200)
check('切到记录页', await visible(page, '#pageSchedLogs'))
const logsText = await textOf(page, '#schedLogsList')
check('记录页出现播报条目', logsText.indexOf('验证事项') >= 0, logsText.slice(0, 100))
check('记录状态为已确认', logsText.indexOf('已确认') >= 0, logsText.slice(0, 100))
// ---------- 6. 错过显示条 ----------
console.log('\n[6] 错过显示条(把时刻改成"已过 2.5 分钟")')
const schedApiRes = await (await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`)).json()
const targetSchedule = (schedApiRes.data || [])[0]
const pastHHmm = beijingHHmm(-150 * 1000)
await fetch(`${BASE}/api/app_order_box/kits/schedule_reminder/schedules`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: targetSchedule.id,
title: targetSchedule.title,
weekdays: targetSchedule.weekdays,
items: [{ id: 'missed-item', time: pastHHmm, name: '错过测试', repeatTimes: 1, intervalSec: 30 }],
}),
})
// 切回"日程"页会触发 loadSchedules 刷新前端缓存,随后 tick 把该时刻判定为错过
await clickByText(page, '#schedTabBar button', '日程')
await sleep(3000)
check('错过显示条已出现', await visible(page, '#schedMissed'))
const missedText = await textOf(page, '#schedMissedDesc')
check('错过条列出该时刻', missedText.indexOf('错过测试') >= 0, missedText)
await clickByText(page, '#btnSchedMissedClear', '知道了')
await sleep(400)
check('点「知道了」后错过条消失', !(await visible(page, '#schedMissed')))
// ---------- 7. 停止待机 ----------
console.log('\n[7] 停止待机')
await page.click('#btnSchedStandby')
await sleep(800)
const stoppedText = await textOf(page, '#schedStatus')
check('已停止待机', stoppedText.indexOf('未待机') >= 0, stoppedText)
// ---------- 8. 清理测试数据(只删本次新建的那一套,绝不碰已有数据) ----------
console.log('\n[8] 清理测试日程')
// 从列表里精确定位"本脚本创建的那张卡片",再点它的删除按钮;
// 绝不能靠"列表里第一个删除按钮"这种位置假设(曾因无差别删除误删过用户真实日程)
const newIds = await page.evaluate(() => {
const ids = Array.from(document.querySelectorAll('#schedulesList .card-item'))
.map(el => el.dataset.id)
return ids.filter(id => !(window.__PRE_EXISTING_IDS || []).includes(id))
})
const createdId = newIds.length ? newIds[0] : null
check('定位到本次新建的日程', !!createdId, JSON.stringify(newIds))
if (createdId) {
await page.evaluate(id => {
const card = document.querySelector('#schedulesList .card-item[data-id="' + id + '"]')
const target = card && Array.from(card.querySelectorAll('button'))
.find(b => String(b.textContent || '').indexOf('删除') >= 0)
if (target) target.click()
}, createdId)
await sleep(300)
await clickByText(page, '#dialogActions button', '确认')
await sleep(3200)
}
const afterDel = await textOf(page, '#schedulesList')
check('测试日程已删除', afterDel.indexOf('自动化验证日程') < 0, afterDel.slice(0, 80))
const preserved = await page.evaluate(ids =>
ids.every(id => !!document.querySelector('#schedulesList .card-item[data-id="' + id + '"]')),
(await page.evaluate(() => window.__PRE_EXISTING_IDS || [])))
check('原有日程未被误删', preserved)
// ---------- 9. 还原框子激活态 ----------
console.log('\n[9] 还原框子激活 kit')
await fetch(`${BASE}/api/app_order_box/state/active-kit`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ kitId: '' }),
})
check('active-kit 已清空(回到 Hub)', true)
}
;(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({ width: 414, height: 900 })
const pageErrors = []
page.on('pageerror', e => pageErrors.push('pageerror: ' + String(e && e.message || e)))
page.on('console', m => { if (m.type() === 'error') pageErrors.push('console.error: ' + m.text()) })
// 用 normal 模式跑:查看类操作降级为 Toast,减少弹窗对点击的遮挡
await page.evaluateOnNewDocument(() => {
try { localStorage.setItem('order_box_ui_mode', 'normal') } catch (e) { /* 静默 */ }
})
let ok = false
try {
await run(page)
// 语音探测弹窗若还在,点掉它,避免影响后续判断
await sleep(300)
ok = failed === 0
} catch (e) {
console.log('\n✘ 验证执行异常:' + String((e && e.stack) || e))
failed += 1
}
console.log('\n--- 页面错误 ---')
if (!pageErrors.length) console.log(' (无 pageerror / console.error)')
else pageErrors.slice(0, 20).forEach(t => console.log(' ! ' + t))
check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '))
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
await browser.close()
server.close(() => {
cleanupDb()
process.exit(ok ? 0 : 1)
})
})()