feat: 新增作息报时器功能并替换短链接前缀为/to/
- 新增作息报时器独立模块,包含完整的日程管理、语音播报和待机功能 - 短链接正式前缀由/l/改为/to/,解决手机小屏下与I、i、1字形混淆问题 - 保留旧前缀/l/、/L/、/I/、/i/兼容已分发的旧短链 - 新增短码大小写兜底匹配,仅唯一匹配时生效避免歧义 - 新增防爆紧急替换功能,可轮换首页鉴权并替换短链接 - 新增多个调试和集成测试脚本,保障数据安全和功能回归
This commit is contained in:
@@ -31,11 +31,66 @@
|
||||
</header>
|
||||
|
||||
<nav id="toolTabs" class="tab-nav" hidden>
|
||||
<button class="tab-btn" data-tab="emergency">首页导航防爆紧急替换</button>
|
||||
<button class="tab-btn active" data-tab="nav">首页导航生成器</button>
|
||||
<button class="tab-btn" data-tab="tool">Tool 令牌生成器</button>
|
||||
</nav>
|
||||
|
||||
<main id="appMain" hidden>
|
||||
<section class="tab-panel" data-panel="emergency">
|
||||
<div class="layout-grid">
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>首页导航防爆紧急替换</h2>
|
||||
</div>
|
||||
<p class="card-desc">
|
||||
怀疑首页 token 或短链接被窥屏、被泄露,或有危险预感时,按顺序点下面两个按钮即可完成更换。
|
||||
旧 token 与旧短链接都会<strong>立即失效</strong>,无需手工改文件、改数据库。
|
||||
</p>
|
||||
|
||||
<div class="actions wrap">
|
||||
<button id="btnEmergencyRotate" class="btn btn-primary">自动生成向后 X 个月的鉴权,且当前鉴权立即失效</button>
|
||||
<select id="emergencyMonths" class="input emergency-months" title="有效期(月)"></select>
|
||||
<button id="btnEmergencyRelink" class="btn btn-primary">将新的首页鉴权token,自动生成新的短链接,且当前短链接立即失效</button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>执行过程</span>
|
||||
<textarea id="emergencyLog" class="textarea mono emergency-log" rows="14" readonly wrap="off" placeholder="点击上方按钮后,这里会滚动显示每一步的执行过程…"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>最终 token(第 1 步产出)</span>
|
||||
<textarea id="emergencyTokenOut" class="textarea mono" rows="4" readonly wrap="off" placeholder="第 1 步完成后,新的首页鉴权 token 会显示在这里"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnEmergencyCopyToken" class="btn">复制 token</button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>最终短链接(第 2 步产出)</span>
|
||||
<textarea id="emergencyShortUrlOut" class="textarea mono" rows="2" readonly wrap="off" placeholder="第 2 步完成后,新的短链接会显示在这里"></textarea>
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button id="btnEmergencyCopyShortUrl" class="btn">复制短链接</button>
|
||||
</div>
|
||||
|
||||
<div id="emergencyMeta" class="result-grid"></div>
|
||||
|
||||
<details class="raw-details">
|
||||
<summary>说明与回滚</summary>
|
||||
<div class="card-desc">
|
||||
第 1 步:轮换导航密钥对(`config/nav-private.jwk.json` 与 `config/nav.jwk.json`),
|
||||
并用新私钥签发一枚有效期 30 天的首页 token。旧 token 会因旧公钥离开信任池而立即失效。<br>
|
||||
第 2 步:把新 token 绑到一条全新的短链接上,同时把旧的首页短链置为停用,旧短链立即失效。<br>
|
||||
两步执行前都会自动备份(密钥文件 + 短链库)到 `config/_nav-key-backup-时间戳/`,需要回滚时把备份覆盖回原位即可。<br>
|
||||
原有的手工做法(首页导航生成器 / Tool 令牌生成器 / 物理替换文件)全部保留有效,本页签只是快捷通道。
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="tab-panel active" data-panel="nav">
|
||||
<div class="layout-grid">
|
||||
<section class="card">
|
||||
|
||||
@@ -269,6 +269,7 @@ body {
|
||||
|
||||
.actions.wrap {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -289,6 +290,33 @@ body {
|
||||
background: linear-gradient(135deg, #6f7bf7 0%, #7c55f2 100%);
|
||||
}
|
||||
|
||||
/* 异步按钮执行中:禁用态视觉(配合 JS 的 disabled + 文案切换) */
|
||||
.btn-busy,
|
||||
.btn:disabled {
|
||||
opacity: 0.62;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 防爆紧急替换:有效期月份下拉,紧跟在按钮后面,宽度收窄不抢按钮视线 */
|
||||
.input.emergency-months {
|
||||
width: auto;
|
||||
min-width: 118px;
|
||||
padding: 11px 14px;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
/* 防爆紧急替换的执行日志:长行自动换行 + 纵向滚动,避免横向撑开 */
|
||||
.textarea.mono.emergency-log {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
min-height: 240px;
|
||||
max-height: 380px;
|
||||
line-height: 1.75;
|
||||
}
|
||||
|
||||
.auth-submit {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@@ -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