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