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
Binary file not shown.

After

Width:  |  Height:  |  Size: 776 KiB

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# 调试:宝龙导视牌 logo 裁切 · 掩膜参数迭代
# 目标:7 行连通域计数 = [11,11,10,11,11,11,11](r2 行牌子上只有 10 格)
# 用法:python dev_test_scripts\debug\debug_mall_crop_mask.py
import os
import sys
import cv2
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
from tool_crop_mall_logos import imread_u, imwrite_u, detect_board_quad, warp_board # noqa: E402
SRC = r'D:\Temp文件\baolong-mall\照片.jpg'
HERE = os.path.dirname(os.path.abspath(__file__))
img = imread_u(SRC)
quad = detect_board_quad(img)
warped = warp_board(img, quad)
wh, ww = warped.shape[:2]
gray = cv2.cvtColor(warped, cv2.COLOR_BGR2GRAY)
hsv = cv2.cvtColor(warped, cv2.COLOR_BGR2HSV)
S = hsv[:, :, 1].astype(np.float32)
V = hsv[:, :, 2].astype(np.float32)
# 局部纹理(box filter 标准差):过曝区颜色失效时靠纹理兜底
gf = gray.astype(np.float32)
m1 = cv2.boxFilter(gf, -1, (9, 9))
m2 = cv2.boxFilter(gf * gf, -1, (9, 9))
STD = np.sqrt(np.maximum(m2 - m1 * m1, 0))
def build_mask(s_th, v_th, t_th, close_k, open_k):
mask = (((S > s_th) | (V < v_th) | (STD > t_th)).astype(np.uint8)) * 255
mask[:60, :] = 0
mask[-60:, :] = 0
mask[:, :60] = 0
mask[:, -60:] = 0
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, np.ones((close_k, close_k), np.uint8))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, np.ones((open_k, open_k), np.uint8))
return mask
def row_counts(mask):
n, labels, stats, cents = cv2.connectedComponentsWithStats(mask, 8)
blocks = []
for i in range(1, n):
x, y, bw, bh, area = stats[i]
cx, cy = cents[i]
if area < 8000 or bw < 100 or bh < 80 or bw > 0.35 * ww or bh > 0.25 * wh:
continue
if cy < 0.17 * wh or cx < 0.06 * ww:
continue
blocks.append((cy, cx, x, y, bw, bh, area))
blocks.sort()
rows = []
for b in blocks:
if rows and abs(b[0] - rows[-1][-1][0]) < 60:
rows[-1].append(b)
else:
rows.append([b])
return [len(r) for r in rows], blocks, rows
combos = [
(45, 140, 999, 9, 7), # 旧参数(仅颜色)
(30, 170, 12, 21, 9),
(25, 180, 10, 25, 11),
(25, 180, 14, 25, 13),
(20, 190, 12, 29, 13),
(30, 170, 16, 21, 11),
]
best = None
for c in combos:
mask = build_mask(*c)
counts, blocks, rows = row_counts(mask)
print(c, '->', counts)
if counts == [11, 11, 10, 11, 11, 11, 11]:
best = (c, mask, rows)
break
if best:
c, mask, rows = best
scale = 1600.0 / ww
vis = cv2.resize(warped, (1600, int(wh * scale))).copy()
for r in rows:
for (cy, cx, x, y, bw, bh, area) in r:
cv2.rectangle(vis, (int(x * scale), int(y * scale)),
(int((x + bw) * scale), int((y + bh) * scale)), (0, 255, 0), 2)
imwrite_u(os.path.join(HERE, 'blocks_preview.jpg'), vis)
imwrite_u(os.path.join(HERE, 'mask_preview.jpg'), cv2.resize(mask, (1600, int(wh * scale))))
print('BEST', c)
else:
print('无完美组合,取最后一组出预览')
mask = build_mask(*combos[-1])
counts, blocks, rows = row_counts(mask)
scale = 1600.0 / ww
vis = cv2.resize(warped, (1600, int(wh * scale))).copy()
for r in rows:
for (cy, cx, x, y, bw, bh, area) in r:
cv2.rectangle(vis, (int(x * scale), int(y * scale)),
(int((x + bw) * scale), int((y + bh) * scale)), (0, 255, 0), 2)
imwrite_u(os.path.join(HERE, 'blocks_preview.jpg'), vis)
imwrite_u(os.path.join(HERE, 'mask_preview.jpg'), cv2.resize(mask, (1600, int(wh * scale))))
+53
View File
@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# 调试:trim_label 单格验证(r0c0 / r3c0 / r2c8)
import os
import sys
import cv2
import numpy as np
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'tools'))
import tool_crop_mall_logos as M # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
img = M.imread_u(M.DEFAULT_SRC)
warped = M.warp_board(img, M.detect_board_quad(img))
blocks = M.detect_confident_blocks(warped)
g = M.fit_grid(blocks)
print('row_top', [round(v) for v in g['row_top']])
print('col_center', [round(v) for v in g['col_center']])
for (ri, ci) in [(4, 1), (3, 8), (0, 0), (2, 8), (3, 6), (6, 10), (5, 0), (6, 0)]:
b = g['grid'].get((ri, ci))
if b is not None:
x0, y0, x1, y1 = b['x'] - 2, b['y'] - 2, b['x'] + b['w'] + 2, b['y'] + b['h'] + 2
kind = 'detect'
else:
cx = g['col_center'][ci]
x0, x1 = int(cx - g['pw'] / 2 - 2), int(cx + g['pw'] / 2 + 2)
y0, y1 = int(g['row_top'][ri] - 2), int(g['row_top'][ri] + g['ph'] + 2)
kind = 'fallback'
crop = warped[y0:y1, x0:x1]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
h = gray.shape[0]
print('--- r%dc%d %s bbox h=%d' % (ri, ci, kind, h))
for y in range(h - 1, max(0, h - 90), -6):
row = gray[y]
print(' y=%3d dark=%.3f mean=%.0f' % (y, float(np.mean(row < 160)), float(np.mean(row))))
t = M.trim_label(crop)
print(' trim: %d -> %d' % (crop.shape[0], t.shape[0]))
if (ri, ci) in [(6, 10), (6, 0)]:
import numpy as _np
gg = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(_np.float32)
dk = (gg < 160).mean(axis=1)
mn = gg.mean(axis=1)
ww = gg.shape[1]
a0, a1 = int(ww * 0.2), int(ww * 0.8)
dc = (gg[:, a0:a1] < 160).mean(axis=1)
do = ((gg[:, :a0] < 160).sum(axis=1) + (gg[:, a1:] < 160).sum(axis=1)) / float(ww - (a1 - a0))
rt = (dc + 0.004) / (do + 0.004)
it = (dk >= 0.04) & (mn <= 220) & (rt > 2.5)
ig = (dk < 0.12) & (mn >= 165) & (~it)
for y in range(crop.shape[0] - 1, int(crop.shape[0] * 0.4), -4):
print(' y=%3d dark=%.3f mean=%3.0f ratio=%5.2f text=%d gap=%d' % (y, dk[y], mn[y], rt[y], it[y], ig[y]))
M.imwrite_u(os.path.join(HERE, 'trim_r%dc%d.jpg' % (ri, ci)), t)
@@ -0,0 +1,119 @@
/**
* 诊断:判定「当前首页 token 到底由哪把密钥签发」
*
* 背景:config/ 下有两把候选公钥参与首页 token 验证:
* - nav-private.jwk.json(含私钥)
* - nav.jwk.json(仅公钥)
* 若不清楚旧 token 实际由哪把私钥签出,就贸然轮换其中一把,
* 可能出现「以为作废了、其实旧的还能用」的假安全。
*
* 本脚本只读,不修改任何密钥或数据:
* 1. 从 data/short_link.db 取当前首页 token
* 2. 用 config 下每一把候选公钥逐一验签,指出「签名来源」
* 3. 打印 token 负载与过期情况
*
* 运行:node dev_test_scripts/debug/debug_nav_token_key_audit.js
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const CONFIG_DIR = path.join(process.cwd(), 'config')
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const b64urlToBuf = s => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64')
const decodePart = s => JSON.parse(b64urlToBuf(s).toString('utf-8'))
const readJwk = name => {
try {
const p = path.join(CONFIG_DIR, name)
if (!fs.existsSync(p)) return null
return JSON.parse(fs.readFileSync(p, 'utf-8'))
} catch { return null }
}
const fingerprint = jwk => {
const n = String((jwk && jwk.n) || '')
if (!n) return '(空)'
return crypto.createHash('sha256').update(n).digest('hex').slice(0, 16)
}
// 从短链库里取一条指向首页(带 token)的目标
const pickHomepageToken = () => {
const db = new Database(DB_PATH, { readonly: true })
const rows = db.prepare('SELECT code, title, target_url FROM short_links').all()
db.close()
for (const r of rows) {
const m = /[?&]token=([^&\s]+)/.exec(String(r.target_url || ''))
if (m) return { code: r.code, title: r.title, url: r.target_url, token: decodeURIComponent(m[1]) }
}
return null
}
;(async () => {
console.log('=== 首页 token 签发来源审计(只读) ===\n')
const found = pickHomepageToken()
if (!found) {
console.log('未在 data/short_link.db 里找到任何带 token 的短链接目标。')
process.exit(0)
}
console.log(`短链接:/${found.code} (${found.title || '无标题'})`)
console.log(`目标:${String(found.url).slice(0, 60)}...`)
console.log(`token 长度:${found.token.length}\n`)
const parts = String(found.token).split('.')
if (parts.length !== 3) {
console.log('✘ token 不是标准三段式 JWT,无法审计')
process.exit(1)
}
const header = decodePart(parts[0])
const payload = decodePart(parts[1])
console.log('--- 负载 ---')
console.log(JSON.stringify(payload, null, 2))
const now = Math.floor(Date.now() / 1000)
console.log(`\n过期:${new Date(payload.exp * 1000).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', hour12: false })}`)
console.log(`剩余:${((payload.exp - now) / 86400).toFixed(1)} 天(${payload.exp > now ? '仍有效' : '已过期'})`)
console.log(`算法:${header.alg}\n`)
console.log('--- 用 config 下每把候选公钥验签 ---')
const data = parts[0] + '.' + parts[1]
const sig = b64urlToBuf(parts[2])
const candidates = [
'nav-private.jwk.json',
'nav.jwk.json',
]
let matched = []
for (const name of candidates) {
const jwk = readJwk(name)
if (!jwk || !jwk.n) {
console.log(` ${name.padEnd(24)} 公钥不可用(无 n),跳过`)
continue
}
let ok = false
try {
const pub = crypto.createPublicKey({ key: { kty: jwk.kty, n: jwk.n, e: jwk.e }, format: 'jwk' })
ok = crypto.verify('RSA-SHA256', Buffer.from(data), pub, sig)
} catch (e) {
console.log(` ${name.padEnd(24)} 验签异常:${String(e.message || e)}`)
continue
}
if (ok) matched.push(name)
console.log(` ${name.padEnd(24)} 指纹=${fingerprint(jwk)} 验签=${ok ? '通过 ← 就是这把签的' : '未通过'}`)
}
console.log('\n--- 结论 ---')
if (matched.length === 0) {
console.log('✘ 没有任何 config 下的公钥能验通该 token。')
console.log(' 说明它是由「其它地方保存的私钥」签出的(例如 token_lab 浏览器里导入的私钥 XML)。')
console.log(' 轮换 config 里的密钥无法作废它 —— 需要先找到那把私钥。')
} else {
console.log(`✔ 该 token 由这些公钥对应私钥签发:${matched.join('、')}`)
console.log(` → 要让旧 token 立即失效,必须轮换${matched.length > 1 ? '全部这些' : '这把'}密钥。`)
matched.forEach(n => console.log(` · ${n}(指纹 ${fingerprint(readJwk(n))})`))
}
})()
@@ -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)
})
})()
@@ -0,0 +1,320 @@
/**
* 作息报时器 · 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) })
})()
@@ -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) })
})()
@@ -0,0 +1,110 @@
/**
* 短链接后台页面 · 前缀渲染确认(一次性调试脚本)
*
* 目的:确认后台页面在界面上真正展示 /to/<短码>(而不是只改了代码但页面没生效)。
* 做法:自建一条一次性短链接 → 打开后台页 → 断言列表与复制按钮里出现 /to/ 且不再出现 /l/ → 删除。
*
* ⚠️ 数据安全:不使用、不点击用户既有短链接;自建的一条测完即删。
*
* 运行:node dev_test_scripts/debug/debug_short_link_admin_render.js
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const puppeteer = require('puppeteer')
const BASE = 'http://localhost:8976'
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const CODE = 'zz' + crypto.randomBytes(3).toString('hex')
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 beijingNow = () => {
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
} catch { return new Date().toISOString() }
}
let passed = 0
let failed = 0
const check = (name, ok, detail) => {
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) }
}
;(async () => {
const db = new Database(DB_PATH)
const userCodes = db.prepare('SELECT code FROM short_links').all().map(r => r.code)
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, created_at) VALUES (?, ?, ?, ?, ?)`)
.run(CODE, 'https://example.com/admin-render-test', '后台渲染确认(临时)', 'manual', beijingNow())
db.close()
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: 1280, height: 900 })
// 带齐两层门禁,直接进后台页
await page.setCookie(
{ name: 'nav_gate', value: '1', domain: 'localhost', path: '/' },
{ name: 'short_link_gate', value: '1', domain: 'localhost', path: '/' },
)
console.log('=== 短链接后台页面 · 前缀渲染确认 ===\n')
await page.goto(`${BASE}/tools/short_link/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const el = document.getElementById('link-tbody')
return el && el.innerText.trim().length > 0
}, { timeout: 15000 })
const rendered = await page.evaluate(() => {
const tbody = document.getElementById('link-tbody')
const aliasList = document.getElementById('alias-list')
const copyBtn = tbody.querySelector('.short-url[data-copy]')
return {
tableText: String((tbody && tbody.innerText) || ''),
copyData: copyBtn ? String(copyBtn.getAttribute('data-copy') || '') : '',
aliasText: String((aliasList && aliasList.innerText) || ''),
aliasCopyData: (() => {
const b = aliasList && aliasList.querySelector('[data-copy]')
return b ? String(b.getAttribute('data-copy') || '') : ''
})(),
hasTo: String(tbody.innerHTML || '').indexOf('/to/') >= 0,
hasLegacyL: /(^|[^a-zA-Z])maise\.pro\/l\//.test(String(tbody.innerHTML || '')),
}
})
check('列表里出现 /to/ 前缀', rendered.hasTo, rendered.tableText.slice(0, 120))
check('列表里不再出现 /l/ 前缀', !rendered.hasLegacyL, rendered.tableText.slice(0, 120))
check('复制按钮携带的地址含 /to/', rendered.copyData.indexOf('/to/') >= 0, rendered.copyData)
check('复制按钮地址形如 https://maise.pro/to/<短码>',
/^https:\/\/maise\.pro\/to\/[A-Za-z0-9]+$/.test(rendered.copyData), rendered.copyData)
check('工具别名区也展示 /to/ 前缀', rendered.aliasCopyData.indexOf('/to/') >= 0, rendered.aliasCopyData)
check(`本次自建短码 ${CODE} 能被界面看到`, rendered.tableText.indexOf(CODE) >= 0, rendered.tableText.slice(0, 120))
await browser.close()
// 清理并确认用户数据未被改动
const db2 = new Database(DB_PATH)
db2.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(CODE)
db2.prepare('DELETE FROM short_links WHERE code = ?').run(CODE)
const remain = db2.prepare('SELECT code FROM short_links').all().map(r => r.code)
db2.close()
check('临时短码已删除', remain.indexOf(CODE) < 0)
check('用户既有短链接一个都没少', userCodes.every(c => remain.indexOf(c) >= 0),
`前 ${userCodes.length} 条 → 后 ${remain.length} 条`)
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})()
@@ -0,0 +1,123 @@
/**
* 复现:iPhone 6s 打开短链接 https://maise.pro/to/<短码> 的完整跳转链路
* (一次性调试脚本)
*
* 目的:用户反馈 iPhone 6s 打开短链接时"提示一个 JSON 而不是跳转"。
* 本脚本用 iPhone 6s 的 UA + 375×667 视口真实走一遍,
* 打印每一次跳转的 URL、状态码、响应类型,以及最终页面渲染出的文本前若干字符,
* 以确定 JSON 究竟出现在哪一跳、是什么内容。
*
* 运行:node dev_test_scripts/debug/debug_short_link_iphone_redirect.js
*/
const fs = require('fs')
const path = require('path')
const crypto = require('crypto')
const Database = require('better-sqlite3')
const puppeteer = require('puppeteer')
// ⚠️ 数据安全:不使用、不点击用户的真实短链接(点击会累加 click_count 并写 clicks 表)。
// 本脚本自建一条一次性短链接走完整跳转链路,结束后删除。
const DB_PATH = path.join(process.cwd(), 'data', 'short_link.db')
const CODE = 'zz' + crypto.randomBytes(3).toString('hex')
const TEST_TARGET = 'https://example.com/redirect-chain-test'
const beijingNow = () => {
try {
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
}).format(new Date()).replace(/\//g, '-').replace(/\u200E/g, '')
} catch { return new Date().toISOString() }
}
const createTempLink = () => {
const db = new Database(DB_PATH)
db.prepare(`INSERT INTO short_links (code, target_url, title, source_type, created_at) VALUES (?, ?, ?, ?, ?)`)
.run(CODE, TEST_TARGET, '跳转链路调试(临时)', 'manual', beijingNow())
db.close()
}
const cleanupTempLink = () => {
try {
const db = new Database(DB_PATH)
db.prepare('DELETE FROM short_link_clicks WHERE code = ?').run(CODE)
db.prepare('DELETE FROM short_links WHERE code = ?').run(CODE)
db.close()
console.log(`\n[清理] 已删除临时短码 ${CODE} 及其点击记录`)
} catch (e) {
console.log(`\n[清理] 失败:${String(e && e.message || e)}`)
}
}
const START_URL = `https://maise.pro/to/${CODE}`
// iPhone 6s 最高可升级到的系统版本
const IPHONE_6S_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_8_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.6.1 Mobile/15E148 Safari/604.1'
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 short = s => String(s || '').replace(/\s+/g, ' ').slice(0, 220)
;(async () => {
createTempLink()
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox', '--mute-audio', '--ignore-certificate-errors'],
})
const page = await browser.newPage()
await page.setUserAgent(IPHONE_6S_UA)
await page.setViewport({ width: 375, height: 667, deviceScaleFactor: 2, isMobile: true, hasTouch: true })
console.log(`模拟 iPhone 6s 打开:${START_URL}`)
console.log(`(临时短码 ${CODE} → ${TEST_TARGET},测完自动删除)\n`)
console.log('--- 跳转链路 ---')
page.on('response', async res => {
const status = res.status()
if (status >= 300 && status < 400) {
console.log(` ${status} ${short(res.url())}`)
console.log(` → Location: ${short(res.headers()['location'])}`)
} else if (res.url().indexOf('maise.pro') >= 0 || res.url().indexOf('/to/') >= 0 || res.url().indexOf('/l/') >= 0) {
console.log(` ${status} ${short(res.url())} [${String(res.headers()['content-type'] || '').split(';')[0]}]`)
}
})
let navError = null
try {
await page.goto(START_URL, { waitUntil: 'networkidle2', timeout: 30000 })
} catch (e) {
navError = String((e && e.message) || e)
console.log(`\n 导航异常:${navError}`)
}
await new Promise(r => setTimeout(r, 2500))
const info = await page.evaluate(() => {
const bodyText = String((document.body && document.body.innerText) || '').trim()
return {
url: location.href,
title: document.title,
contentType: document.contentType,
hasBodyEl: !!document.body,
bodyLen: bodyText.length,
text: bodyText.slice(0, 400),
htmlHead: String(document.documentElement.innerHTML || '').slice(0, 400),
}
})
console.log('\n--- 最终落点 ---')
console.log(` URL: ${info.url}`)
console.log(` 文档类型: ${info.contentType}`)
console.log(` 标题: ${info.title}`)
console.log(` body 文本长度: ${info.bodyLen}`)
console.log(` body 文本: ${short(info.text)}`)
console.log(` 是否像 JSON: ${/^\s*[\[{]/.test(info.text) ? '是 ← 复现到了' : '否'}`)
console.log(` 是否落到目标链接: ${info.url.indexOf('example.com/redirect-chain-test') >= 0 ? '是 ✔' : '否 ✘'}`)
await browser.close()
cleanupTempLink()
process.exit(0)
})()
@@ -0,0 +1,235 @@
/**
* 「首页导航防爆紧急替换」页签 · 前端界面验证
*
* 目的:验证页签位置、两个按钮、滚动日志、最终 token / 短链接展示都正常工作。
*
* ⚠️ 本脚本不真实触发换密钥:对 /api/token_lab/nav/emergency/* 做请求拦截,
* 返回预置的假响应,只验证前端渲染与交互,绝不改动生产的密钥与短链库。
*
* 运行:node dev_test_scripts/debug/debug_token_lab_emergency_ui.js
*/
const fs = require('fs')
const puppeteer = require('puppeteer')
const BASE = process.env.TL_BASE || 'http://127.0.0.1:8976'
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 passed = 0
let failed = 0
const check = (name, ok, detail) => {
if (ok) { passed += 1; console.log(` ✔ ${name}`) }
else { failed += 1; console.log(` ✘ ${name}${detail ? ' → ' + detail : ''}`) }
}
const FAKE_TOKEN = 'eyJmYWtlLXRva2VuLWZvci11aS10ZXN0.' + 'A'.repeat(120) + '.signature'
const FAKE_SHORT = 'https://maise.pro/to/UiTest1'
const ROTATE_LOGS = [
'【第 1 步】轮换导航密钥 + 签发新 token',
'目标有效期:30 天',
'已备份原有文件 → config/_nav-key-backup-2026-09-30T16-00-00/(nav-private.jwk.json、nav.jwk.json、short_link.db)',
'已生成新的 RSA-2048 密钥对(公钥指纹 abcdef1234567890)',
'已写入 config/nav-private.jwk.json(含私钥,签发用)',
'已写入 config/nav.jwk.json(仅公钥,信任池用)',
'两处已换成同一把新钥匙 → 旧公钥彻底离开信任池,旧 token 自此刻起失效',
'已用新私钥签发 token(iss=TRAE-NAV,jti=fake-jti)',
'自校验:签名=通过|有效期=正常|iss=通过',
'完成 ✅ 新 token 过期时间:2026/10/30 16:00:00(北京时间)',
]
const RELINK_LOGS = [
'【第 2 步】更换首页短链接',
'正在操作短链接数据库 data/short_link.db',
'token 校验通过(jti=fake-jti,过期 2026/10/30 16:00:00)',
'已停用旧短链 /to/PSq6xv → 立即失效',
'已创建新短链:https://maise.pro/to/UiTest1',
'自校验:新短链返回 302,跳转目标与新 token 一致 ✅',
'完成 ✅ 最终短链接:https://maise.pro/to/UiTest1',
]
;(async () => {
const browser = await puppeteer.launch({
headless: true, executablePath: findChrome(),
args: ['--no-sandbox', '--disable-setuid-sandbox'],
})
const page = await browser.newPage()
await page.setViewport({ width: 1280, height: 1000 })
await page.setCookie(
{ name: 'nav_gate', value: '1', domain: '127.0.0.1', path: '/' },
{ name: 'token_lab_gate', value: '1', domain: '127.0.0.1', path: '/' },
)
const pageErrors = []
page.on('pageerror', e => pageErrors.push(String(e && e.message || e)))
// 拦截两个紧急接口 → 返回预置响应,绝不真实执行
await page.setRequestInterception(true)
let rotateHit = 0
let relinkHit = 0
let rotateBody = null
page.on('request', req => {
const url = req.url()
if (url.includes('/api/token_lab/nav/emergency/rotate')) {
rotateHit += 1
try { rotateBody = JSON.parse(req.postData() || '{}') } catch { rotateBody = null }
return req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({
ok: true, token: FAKE_TOKEN, exp: Math.floor(Date.now() / 1000) + 30 * 86400,
months: (rotateBody && rotateBody.months) || 1,
days: ((rotateBody && rotateBody.months) || 1) * 30,
fingerprint: 'abcdef1234567890', backupDir: '_nav-key-backup-2026-09-30T16-00-00', logs: ROTATE_LOGS,
}),
})
}
if (url.includes('/api/token_lab/nav/emergency/relink')) {
relinkHit += 1
return req.respond({
status: 200, contentType: 'application/json',
body: JSON.stringify({
ok: true, code: 'UiTest1', shortUrl: FAKE_SHORT, disabledCodes: ['PSq6xv'], verified: true, logs: RELINK_LOGS,
}),
})
}
return req.continue()
})
console.log('=== 防爆紧急替换页签 · 前端界面验证(接口已拦截,不会真实执行) ===\n')
await page.goto(`${BASE}/tools/token_lab/index.html`, { waitUntil: 'networkidle2', timeout: 30000 })
await page.waitForFunction(() => {
const m = document.getElementById('appMain')
return m && !m.hidden
}, { timeout: 15000 })
// ---------- 1. 页签位置与文案 ----------
console.log('[1] 页签与按钮')
const tabs = await page.$$eval('.tab-btn', els => els.map(e => ({ text: e.textContent.trim(), tab: e.dataset.tab, active: e.classList.contains('active') })))
check('共有 3 个页签', tabs.length === 3, JSON.stringify(tabs.map(t => t.text)))
check('新页签排在第一位', tabs[0] && tabs[0].tab === 'emergency', JSON.stringify(tabs[0] || {}))
check('新页签名称正确', tabs[0] && tabs[0].text === '首页导航防爆紧急替换', tabs[0] && tabs[0].text)
check('原「首页导航生成器」仍在且为默认激活', tabs[1] && tabs[1].tab === 'nav' && tabs[1].active === true, JSON.stringify(tabs[1] || {}))
check('原「Tool 令牌生成器」仍在', tabs[2] && tabs[2].tab === 'tool')
const btnTexts = await page.evaluate(() => ({
b1: document.getElementById('btnEmergencyRotate') ? document.getElementById('btnEmergencyRotate').textContent.trim() : '',
b2: document.getElementById('btnEmergencyRelink') ? document.getElementById('btnEmergencyRelink').textContent.trim() : '',
}))
check('按钮 1 文案为动态月份(默认 1 个月)',
btnTexts.b1 === '自动生成向后 1 个月的鉴权,且当前鉴权立即失效', btnTexts.b1)
check('按钮 2 文案正确', btnTexts.b2 === '将新的首页鉴权token,自动生成新的短链接,且当前短链接立即失效', btnTexts.b2)
// ---------- 1b. 月份下拉 ----------
console.log('\n[1b] 有效期月份下拉(1~12,默认 1)')
const selInfo = await page.evaluate(() => {
const sel = document.getElementById('emergencyMonths')
if (!sel) return null
return {
exists: true,
value: sel.value,
options: Array.from(sel.options).map(o => o.value),
labels: Array.from(sel.options).map(o => o.textContent.trim()),
// 是否紧跟按钮 1 之后
afterButton: (() => {
const btn = document.getElementById('btnEmergencyRotate')
return !!(btn && btn.nextElementSibling === sel)
})(),
}
})
check('下拉存在', !!(selInfo && selInfo.exists))
check('默认选中 1 个月', selInfo && selInfo.value === '1', selInfo && selInfo.value)
check('共 12 个选项', !!(selInfo && selInfo.options.length === 12), selInfo && selInfo.options.length)
check('选项值为 1~12',
!!(selInfo && JSON.stringify(selInfo.options) === JSON.stringify(['1','2','3','4','5','6','7','8','9','10','11','12'])),
selInfo && JSON.stringify(selInfo.options))
check('选项文案形如「N 个月」', !!(selInfo && selInfo.labels[0] === '1 个月' && selInfo.labels[11] === '12 个月'),
selInfo && JSON.stringify(selInfo.labels))
check('下拉位于按钮 1 之后(同一行)', !!(selInfo && selInfo.afterButton))
// 切换下拉 → 按钮文案应跟着变
await page.select('#emergencyMonths', '6')
await sleep(200)
const btnAfterChange = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())
check('切到 6 个月后按钮文案同步更新',
btnAfterChange === '自动生成向后 6 个月的鉴权,且当前鉴权立即失效', btnAfterChange)
await page.select('#emergencyMonths', '12')
await sleep(200)
const btnAfter12 = await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())
check('切到 12 个月后按钮文案同步更新',
btnAfter12 === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效', btnAfter12)
// ---------- 2. 切到新页签 ----------
console.log('\n[2] 切换到新页签')
await page.evaluate(() => document.querySelector('.tab-btn[data-tab="emergency"]').click())
await sleep(250)
const panelShown = await page.evaluate(() => {
const p = document.querySelector('.tab-panel[data-panel="emergency"]')
return !!p && p.classList.contains('active') && window.getComputedStyle(p).display !== 'none'
})
check('新页签面板已显示', panelShown)
// ---------- 3. 点按钮 1 ----------
console.log('\n[3] 点击按钮 1(自动生成向后一个月的鉴权…)')
await page.click('#btnEmergencyRotate')
await sleep(2500) // 等日志逐行播放完
const after1 = await page.evaluate(() => ({
log: document.getElementById('emergencyLog').value,
token: document.getElementById('emergencyTokenOut').value,
meta: document.getElementById('emergencyMeta').innerText,
}))
check('确实调用了 rotate 接口', rotateHit === 1, `命中 ${rotateHit} 次`)
check('请求体带上了所选月份 months=12', !!(rotateBody && Number(rotateBody.months) === 12),
JSON.stringify(rotateBody))
check('日志框出现滚动内容', after1.log.length > 100, `长度 ${after1.log.length}`)
check('日志含第 1 步标题', after1.log.includes('【第 1 步】'))
check('日志含"完成"', after1.log.includes('完成 ✅'))
check('日志逐行带时间戳', /\[\d{2}:\d{2}:\d{2}\]/.test(after1.log))
check('最终 token 已展示', after1.token === FAKE_TOKEN, `长度 ${after1.token.length}`)
check('元信息展示公钥指纹', after1.meta.includes('abcdef1234567890'), after1.meta.slice(0, 80))
check('元信息展示有效期 12 个月', after1.meta.includes('12 个月'), after1.meta.slice(0, 120))
check('执行后按钮文案恢复为所选月份',
(await page.evaluate(() => document.getElementById('btnEmergencyRotate').textContent.trim())) === '自动生成向后 12 个月的鉴权,且当前鉴权立即失效')
// ---------- 4. 点按钮 2 ----------
console.log('\n[4] 点击按钮 2(将新 token 绑到新短链…)')
await page.click('#btnEmergencyRelink')
await sleep(2000)
const after2 = await page.evaluate(() => ({
log: document.getElementById('emergencyLog').value,
shortUrl: document.getElementById('emergencyShortUrlOut').value,
meta: document.getElementById('emergencyMeta').innerText,
}))
check('确实调用了 relink 接口', relinkHit === 1, `命中 ${relinkHit} 次`)
check('日志含第 2 步标题', after2.log.includes('【第 2 步】'))
check('日志含停用旧短链步骤', after2.log.includes('已停用旧短链'))
check('最终短链接已展示', after2.shortUrl === FAKE_SHORT, after2.shortUrl)
check('元信息展示已停用的旧短码', after2.meta.includes('PSq6xv'), after2.meta.slice(0, 120))
check('元信息展示自校验结果', after2.meta.includes('通过'), after2.meta.slice(0, 120))
// ---------- 5. 其它页签未受影响 ----------
console.log('\n[5] 原有功能未受影响')
for (const t of ['nav', 'tool']) {
await page.evaluate(tab => document.querySelector(`.tab-btn[data-tab="${tab}"]`).click(), t)
await sleep(150)
const ok = await page.evaluate(tab => {
const p = document.querySelector(`.tab-panel[data-panel="${tab}"]`)
return !!p && p.classList.contains('active')
}, t)
check(`原页签 ${t} 仍可切换`, ok)
}
check('页面无 JS 报错', pageErrors.length === 0, pageErrors.slice(0, 3).join(' | '))
await browser.close()
console.log(`\n=== 结果:通过 ${passed} 项,失败 ${failed} 项 ===`)
process.exit(failed === 0 ? 0 : 1)
})().catch(e => {
console.log('\n✘ 执行异常:' + String((e && e.stack) || e))
process.exit(1)
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

@@ -0,0 +1,186 @@
{
"warped_size": [
3944,
2819
],
"quad": [
[
87,
36
],
[
4031,
54
],
[
3893,
2870
],
[
117,
2800
]
],
"confident": 27,
"detected": [
"r0c0",
"r0c1",
"r1c0",
"r4c3",
"r4c4",
"r4c5",
"r4c6",
"r4c7",
"r4c8",
"r4c9",
"r4c10",
"r5c3",
"r5c4",
"r5c5",
"r5c6",
"r5c7",
"r5c8",
"r5c9",
"r5c10",
"r6c2",
"r6c3",
"r6c4",
"r6c5",
"r6c6",
"r6c7",
"r6c8",
"r6c9"
],
"fallback": [
"r0c2",
"r0c3",
"r0c4",
"r0c5",
"r0c6",
"r0c7",
"r0c8",
"r0c9",
"r0c10",
"r1c1",
"r1c2",
"r1c3",
"r1c4",
"r1c5",
"r1c6",
"r1c7",
"r1c8",
"r1c9",
"r1c10",
"r2c0",
"r2c1",
"r2c2",
"r2c3",
"r2c4",
"r2c5",
"r2c6",
"r2c7",
"r2c8",
"r2c9",
"r3c0",
"r3c1",
"r3c2",
"r3c3",
"r3c4",
"r3c5",
"r3c6",
"r3c7",
"r3c8",
"r3c9",
"r3c10",
"r4c0",
"r4c1",
"r4c2",
"r5c0",
"r5c1",
"r5c2",
"r6c0",
"r6c1",
"r6c10"
],
"skipped": [
"r2c10"
],
"written": [
"r0c0",
"r0c1",
"r0c10",
"r0c2",
"r0c3",
"r0c4",
"r0c5",
"r0c6",
"r0c7",
"r0c8",
"r0c9",
"r1c0",
"r1c1",
"r1c10",
"r1c2",
"r1c3",
"r1c4",
"r1c5",
"r1c6",
"r1c7",
"r1c8",
"r1c9",
"r2c0",
"r2c1",
"r2c2",
"r2c3",
"r2c4",
"r2c5",
"r2c6",
"r2c7",
"r2c8",
"r2c9",
"r3c0",
"r3c1",
"r3c10",
"r3c2",
"r3c3",
"r3c4",
"r3c5",
"r3c6",
"r3c7",
"r3c8",
"r3c9",
"r4c0",
"r4c1",
"r4c10",
"r4c2",
"r4c3",
"r4c4",
"r4c5",
"r4c6",
"r4c7",
"r4c8",
"r4c9",
"r5c0",
"r5c1",
"r5c10",
"r5c2",
"r5c3",
"r5c4",
"r5c5",
"r5c6",
"r5c7",
"r5c8",
"r5c9",
"r6c0",
"r6c1",
"r6c10",
"r6c2",
"r6c3",
"r6c4",
"r6c5",
"r6c6",
"r6c7",
"r6c8",
"r6c9"
]
}
+430
View File
@@ -0,0 +1,430 @@
{
"model": "qwen-vl-plus",
"batches": [
{
"ids": [
"r0c0",
"r0c1",
"r0c2",
"r0c3"
],
"bad": [
{
"id": "r0c0",
"problem": "mismatch"
},
{
"id": "r0c1",
"problem": "mismatch"
},
{
"id": "r0c2",
"problem": "mismatch"
},
{
"id": "r0c3",
"problem": "mismatch"
}
]
},
{
"ids": [
"r0c4",
"r0c5",
"r0c6",
"r0c7"
],
"bad": [
{
"id": "r0c4",
"problem": "mismatch"
},
{
"id": "r0c5",
"problem": "mismatch"
},
{
"id": "r0c6",
"problem": "mismatch"
},
{
"id": "r0c7",
"problem": "mismatch"
}
]
},
{
"ids": [
"r0c8",
"r0c9",
"r0c10",
"r1c0"
],
"bad": [
{
"id": "r0c10",
"problem": "cut"
}
]
},
{
"ids": [
"r1c1",
"r1c2",
"r1c3",
"r1c4"
],
"bad": [
{
"id": "r1c1",
"problem": "cut"
},
{
"id": "r1c2",
"problem": "cut"
},
{
"id": "r1c3",
"problem": "cut"
},
{
"id": "r1c4",
"problem": "cut"
}
]
},
{
"ids": [
"r1c5",
"r1c6",
"r1c7",
"r1c8"
],
"bad": [
{
"id": "r1c5",
"problem": null
},
{
"id": "r1c6",
"problem": null
},
{
"id": "r1c7",
"problem": null
},
{
"id": "r1c8",
"problem": null
}
]
},
{
"ids": [
"r1c9",
"r1c10",
"r2c0",
"r2c1"
],
"bad": [
{
"id": "r1c10",
"problem": "mismatch"
},
{
"id": "r2c0",
"problem": "mismatch"
}
]
},
{
"ids": [
"r2c2",
"r2c3",
"r2c4",
"r2c5"
],
"bad": [
{
"id": "r2c4",
"problem": "cut"
}
]
},
{
"ids": [
"r2c6",
"r2c7",
"r2c8",
"r2c9"
],
"bad": [
{
"id": "r2c7",
"problem": "mismatch"
},
{
"id": "r2c8",
"problem": "label"
}
]
},
{
"ids": [
"r2c10",
"r3c0",
"r3c1",
"r3c2"
],
"bad": [
{
"id": "r3c1",
"problem": "mismatch"
},
{
"id": "r3c2",
"problem": "mismatch"
}
]
},
{
"ids": [
"r3c3",
"r3c4",
"r3c5",
"r3c6"
],
"bad": [
{
"id": "r3c6",
"problem": "mismatch"
}
]
},
{
"ids": [
"r3c7",
"r3c8",
"r3c9",
"r3c10"
],
"bad": [
{
"id": "r3c7",
"problem": "cut"
},
{
"id": "r3c8",
"problem": "mismatch"
},
{
"id": "r3c9",
"problem": "mismatch"
},
{
"id": "r3c10",
"problem": "mismatch"
}
]
},
{
"ids": [
"r4c0",
"r4c1",
"r4c2",
"r4c3"
],
"bad": [
{
"id": "r4c0",
"problem": "mismatch"
},
{
"id": "r4c1",
"problem": "mismatch"
},
{
"id": "r4c2",
"problem": "mismatch"
},
{
"id": "r4c3",
"problem": "mismatch"
}
]
},
{
"ids": [
"r4c4",
"r4c5",
"r4c6",
"r4c7"
],
"bad": [
{
"id": "r4c4",
"problem": "mismatch"
},
{
"id": "r4c5",
"problem": "mismatch"
},
{
"id": "r4c6",
"problem": "mismatch"
},
{
"id": "r4c7",
"problem": "mismatch"
}
]
},
{
"ids": [
"r4c8",
"r4c9",
"r4c10",
"r5c0"
],
"bad": [
{
"id": "r4c8",
"problem": "mismatch"
},
{
"id": "r5c0",
"problem": "label"
}
]
},
{
"ids": [
"r5c1",
"r5c2",
"r5c3",
"r5c4"
],
"bad": [
{
"id": "r5c1",
"problem": "mismatch"
},
{
"id": "r5c2",
"problem": "mismatch"
},
{
"id": "r5c3",
"problem": "mismatch"
},
{
"id": "r5c4",
"problem": "mismatch"
}
]
},
{
"ids": [
"r5c5",
"r5c6",
"r5c7",
"r5c8"
],
"bad": [
{
"id": "r5c5",
"problem": "mismatch"
},
{
"id": "r5c6",
"problem": "mismatch"
},
{
"id": "r5c7",
"problem": "mismatch"
},
{
"id": "r5c8",
"problem": "mismatch"
}
]
},
{
"ids": [
"r5c9",
"r5c10",
"r6c0",
"r6c1"
],
"bad": [
{
"id": "r6c0",
"problem": "label"
}
]
},
{
"ids": [
"r6c2",
"r6c3",
"r6c4",
"r6c5"
],
"bad": [
{
"id": "r6c2",
"problem": "mismatch"
},
{
"id": "r6c3",
"problem": "mismatch"
},
{
"id": "r6c4",
"problem": "mismatch"
},
{
"id": "r6c5",
"problem": "mismatch"
}
]
},
{
"ids": [
"r6c6",
"r6c7",
"r6c8",
"r6c9"
],
"bad": [
{
"id": "r6c6",
"problem": "mismatch"
},
{
"id": "r6c7",
"problem": "mismatch"
},
{
"id": "r6c8",
"problem": "mismatch"
},
{
"id": "r6c9",
"problem": "mismatch"
}
]
},
{
"ids": [
"r6c10"
],
"bad": [
{
"id": "r6c10",
"problem": "mismatch"
}
]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

@@ -0,0 +1,56 @@
/**
* 恢复被误删的作息日程(一次性数据恢复脚本)
*
* 背景:调试脚本的"清理"步骤误删了用户真实创建的日程。
* 作息日程的删除是**软删除**(schedules.deleted = 1),数据行与 items_json 都还在,
* 因此可直接把 deleted 改回 0 完成恢复,无需重建。
*
* 用法:
* node dev_test_scripts/debug/tool_restore_deleted_schedule.js # 列出所有已软删的日程
* node dev_test_scripts/debug/tool_restore_deleted_schedule.js <scheduleId> # 恢复指定 id
*/
const path = require('path')
const Database = require('better-sqlite3')
const DB_PATH = path.join(process.cwd(), 'data', 'app_order_box_schedule_reminder.db')
const db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
const targetId = String(process.argv[2] || '').trim()
const safeParse = raw => {
try { return JSON.parse(raw || '[]') } catch { return [] }
}
if (!targetId) {
const rows = db.prepare(`
SELECT id, title, weekdays, items_json, created_at, updated_at
FROM schedules WHERE deleted = 1 ORDER BY datetime(created_at) DESC
`).all()
console.log(`已软删除的日程共 ${rows.length} 条:\n`)
rows.forEach(r => {
const items = safeParse(r.items_json)
console.log(`id=${r.id}`)
console.log(` 标题: ${r.title} 生效星期: [${r.weekdays}] 时刻数: ${items.length}`)
console.log(` 创建: ${r.created_at} 最近更新: ${r.updated_at}`)
console.log(` 时刻: ${items.map(i => i.time + ' ' + i.name).join(' | ')}`)
console.log('')
})
console.log('恢复某一条:node dev_test_scripts/debug/tool_restore_deleted_schedule.js <id>')
} else {
const row = db.prepare('SELECT id, title, deleted FROM schedules WHERE id = ?').get(targetId)
if (!row) {
console.log(`未找到 id=${targetId}`)
process.exit(1)
}
if (!row.deleted) {
console.log(`id=${targetId}「${row.title}」本来就是正常状态,无需恢复`)
process.exit(0)
}
db.prepare('UPDATE schedules SET deleted = 0 WHERE id = ?').run(targetId)
const after = db.prepare('SELECT id, title, deleted, items_json FROM schedules WHERE id = ?').get(targetId)
console.log(`已恢复:id=${after.id} 标题=${after.title} deleted=${after.deleted}`)
console.log(`时刻:${safeParse(after.items_json).map(i => i.time + ' ' + i.name).join(' | ')}`)
}
db.close()
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB