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