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
+173 -1
View File
@@ -9,7 +9,9 @@
toolPresets: [],
currentPresetId: '',
toolKeys: null,
authenticated: false
authenticated: false,
// 防爆紧急替换:第 1 步产出的新 token,供第 2 步绑定短链接使用
emergencyToken: ''
}
const el = {
@@ -33,6 +35,12 @@
navVerifyMeta: $('#navVerifyMeta'),
navVerifyRaw: $('#navVerifyRaw'),
emergencyLog: $('#emergencyLog'),
emergencyMonths: $('#emergencyMonths'),
emergencyTokenOut: $('#emergencyTokenOut'),
emergencyShortUrlOut: $('#emergencyShortUrlOut'),
emergencyMeta: $('#emergencyMeta'),
toolPresetSelect: $('#toolPresetSelect'),
toolPresetName: $('#toolPresetName'),
toolBaseUrl: $('#toolBaseUrl'),
@@ -744,6 +752,163 @@
URL.revokeObjectURL(url)
}
// ===== 首页导航防爆紧急替换 =====
// 两个按钮串行:先轮换密钥+签发新 token,再把新 token 绑到新短链并让旧短链失效。
// 执行过程按后端回传的步骤逐行滚动展示,最后把 token / 短链接单独显示,方便直接复制。
const EMERGENCY_STEP_DELAY = 110
const EMERGENCY_MONTH_OPTIONS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
const EMERGENCY_DEFAULT_MONTHS = 1
// 每月按 30 天计算:与后端 days 参数对接,避免月初月末的天数歧义
const EMERGENCY_DAYS_PER_MONTH = 30
/** 初始化月份下拉:1~12 个月,默认 1 个月,并让按钮文字跟着选中值走 */
function initEmergencyMonths() {
el.emergencyMonths.innerHTML = EMERGENCY_MONTH_OPTIONS
.map(m => `<option value="${m}">${m} 个月</option>`)
.join('')
el.emergencyMonths.value = String(EMERGENCY_DEFAULT_MONTHS)
el.emergencyMonths.addEventListener('change', renderEmergencyRotateButtonText)
renderEmergencyRotateButtonText()
}
function currentEmergencyMonths() {
const m = Number(el.emergencyMonths.value)
return EMERGENCY_MONTH_OPTIONS.includes(m) ? m : EMERGENCY_DEFAULT_MONTHS
}
/** 按钮文案随下拉实时变化,避免"固定写一个月、实际却按别的月份执行"的歧义 */
function renderEmergencyRotateButtonText() {
const btn = $('#btnEmergencyRotate')
if (!btn || btn.disabled) return // 执行中不要覆盖"执行中…"的文案
btn.textContent = `自动生成向后 ${currentEmergencyMonths()} 个月的鉴权,且当前鉴权立即失效`
}
/** 按钮异步态:禁用 + 文字切换(项目规则:所有异步按钮都要有 loading) */
function setButtonBusy(btn, busy, busyText) {
if (!btn) return
if (busy) {
if (btn.dataset.originalText === undefined) btn.dataset.originalText = btn.textContent
btn.disabled = true
btn.classList.add('btn-busy')
if (busyText) btn.textContent = busyText
} else {
btn.disabled = false
btn.classList.remove('btn-busy')
if (btn.dataset.originalText !== undefined) {
btn.textContent = btn.dataset.originalText
delete btn.dataset.originalText
}
}
}
function emergencyLogClear() {
el.emergencyLog.value = ''
el.emergencyMeta.innerHTML = ''
}
function emergencyLogAppend(line) {
const stamp = new Date().toLocaleTimeString('zh-CN', { hour12: false })
el.emergencyLog.value += `[${stamp}] ${line}\n`
el.emergencyLog.scrollTop = el.emergencyLog.scrollHeight
}
/** 逐行播放后端回传的步骤,营造滚动输出的效果 */
async function emergencyLogPlay(lines) {
for (const line of lines || []) {
emergencyLogAppend(line)
await new Promise(resolve => setTimeout(resolve, EMERGENCY_STEP_DELAY))
}
}
/** 与 fetchJson 的区别:失败时也把后端返回的 logs 取回来展示,便于排障 */
async function emergencyPost(url, body) {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {})
})
let data = null
try { data = await resp.json() } catch { data = null }
if (resp.status === 401) {
state.authenticated = false
applyAuthUi()
}
return { ok: !!(resp.ok && data && data.ok !== false), data }
}
async function handleEmergencyRotate() {
const btn = $('#btnEmergencyRotate')
const months = currentEmergencyMonths()
setButtonBusy(btn, true, '执行中…')
try {
emergencyLogClear()
state.emergencyToken = ''
el.emergencyTokenOut.value = ''
el.emergencyShortUrlOut.value = ''
emergencyLogAppend(`开始执行:自动生成向后 ${months} 个月的鉴权,且当前鉴权立即失效`)
const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/rotate', { months })
await emergencyLogPlay(data && data.logs)
if (!ok || !data) {
emergencyLogAppend('✘ 第 1 步未成功,请查看上面的执行过程')
setNotice('第 1 步失败,请查看执行过程。', 'error')
return
}
state.emergencyToken = String(data.token || '')
el.emergencyTokenOut.value = state.emergencyToken
const effectiveDays = Number(data.days) || months * EMERGENCY_DAYS_PER_MONTH
el.emergencyMeta.innerHTML = createKvHtml([
{ label: '有效期', value: `${months} 个月(${effectiveDays} 天)` },
{ label: '公钥指纹', value: data.fingerprint || '-' },
{ label: '过期(北京时间)', value: formatEpoch(data.exp) },
{ label: '剩余时长', value: formatRelative(data.exp), status: 'good' },
{ label: '备份目录', value: data.backupDir || '-' }
])
setNotice(`第 1 步完成:旧鉴权已失效,新的 ${months} 个月 token 已生成。`, 'success')
} catch (err) {
emergencyLogAppend(`✘ 失败:${err.message || err}`)
setNotice(`第 1 步失败:${err.message || err}`, 'error')
} finally {
setButtonBusy(btn, false)
// 按钮文案依赖下拉选中值,恢复时按当前选择重算,而不是回退到旧文案
renderEmergencyRotateButtonText()
}
}
async function handleEmergencyRelink() {
const btn = $('#btnEmergencyRelink')
if (!state.emergencyToken) {
emergencyLogAppend('✘ 还没有可用的新 token,请先点第 1 个按钮生成。')
setNotice('请先执行第 1 步生成新 token。', 'error')
return
}
setButtonBusy(btn, true, '执行中…')
try {
emergencyLogAppend('开始执行:将新 token 绑定到新的短链接,且旧短链接立即失效')
const { ok, data } = await emergencyPost('/api/token_lab/nav/emergency/relink', { token: state.emergencyToken })
await emergencyLogPlay(data && data.logs)
if (!ok || !data) {
emergencyLogAppend('✘ 第 2 步未成功,请查看上面的执行过程')
setNotice('第 2 步失败,请查看执行过程。', 'error')
return
}
el.emergencyShortUrlOut.value = String(data.shortUrl || '')
el.emergencyMeta.innerHTML = createKvHtml([
{ label: '新短码', value: data.code || '-' },
{ label: '已停用的旧短码', value: (data.disabledCodes || []).join('、') || '(无)' },
{ label: '跳转自校验', value: data.verified ? '通过' : '未通过(请手工确认)', status: data.verified ? 'good' : 'bad' }
])
setNotice('第 2 步完成:新短链接已生效,旧短链接已失效。', 'success')
} catch (err) {
emergencyLogAppend(`✘ 失败:${err.message || err}`)
setNotice(`第 2 步失败:${err.message || err}`, 'error')
} finally {
setButtonBusy(btn, false)
}
}
function bindEvents() {
el.tabButtons.forEach((btn) => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab))
@@ -752,6 +917,12 @@
$('#btnNavGenerate').addEventListener('click', handleNavGenerate)
$('#btnNavCopy').addEventListener('click', () => copyText(el.navLinkOutput.value, '首页链接已复制。'))
$('#btnNavVerify').addEventListener('click', handleNavVerify)
// 首页导航防爆紧急替换
$('#btnEmergencyRotate').addEventListener('click', handleEmergencyRotate)
$('#btnEmergencyRelink').addEventListener('click', handleEmergencyRelink)
$('#btnEmergencyCopyToken').addEventListener('click', () => copyText(el.emergencyTokenOut.value, 'token 已复制。'))
$('#btnEmergencyCopyShortUrl').addEventListener('click', () => copyText(el.emergencyShortUrlOut.value, '短链接已复制。'))
$('#btnAuthLogin').addEventListener('click', handleAuthLogin)
el.authUsername.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleAuthLogin()
@@ -801,6 +972,7 @@
loadPresets()
renderPresetSelect()
bindEvents()
initEmergencyMonths()
el.navBaseUrl.value = `${window.location.origin}/`
el.navExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 30)
el.toolExpiry.value = toDatetimeLocalValue(Date.now() + 1000 * 60 * 60 * 24 * 7)