(() => {
const $ = (sel) => document.querySelector(sel)
const $$ = (sel) => Array.from(document.querySelectorAll(sel))
const STORAGE_PRESETS = 'token_lab.tool_presets.v1'
const STORAGE_CURRENT_PRESET = 'token_lab.tool_current_preset.v1'
const state = {
toolPresets: [],
currentPresetId: '',
toolKeys: null,
authenticated: false
}
const el = {
workshopShell: $('#workshopShell'),
notice: $('#globalNotice'),
tabButtons: $$('.tab-btn'),
tabPanels: $$('.tab-panel'),
authScreen: $('#authScreen'),
toolTabs: $('#toolTabs'),
appMain: $('#appMain'),
authUsername: $('#authUsername'),
authPassword: $('#authPassword'),
authHint: $('#authHint'),
btnAuthLogout: $('#btnAuthLogout'),
navBaseUrl: $('#navBaseUrl'),
navExpiry: $('#navExpiry'),
navLinkOutput: $('#navLinkOutput'),
navGenerateMeta: $('#navGenerateMeta'),
navVerifyInput: $('#navVerifyInput'),
navVerifyMeta: $('#navVerifyMeta'),
navVerifyRaw: $('#navVerifyRaw'),
toolPresetSelect: $('#toolPresetSelect'),
toolPresetName: $('#toolPresetName'),
toolBaseUrl: $('#toolBaseUrl'),
toolAlias: $('#toolAlias'),
toolExpiry: $('#toolExpiry'),
toolPassword: $('#toolPassword'),
toolXmlFile: $('#toolXmlFile'),
toolPublicJwk: $('#toolPublicJwk'),
toolLinkOutput: $('#toolLinkOutput'),
toolKeyMeta: $('#toolKeyMeta'),
toolGenerateMeta: $('#toolGenerateMeta'),
toolVerifyInput: $('#toolVerifyInput'),
toolVerifyMeta: $('#toolVerifyMeta'),
toolVerifyRaw: $('#toolVerifyRaw')
}
function setNotice(message, type = '') {
if (!message) {
el.notice.hidden = true
el.notice.className = 'notice'
el.notice.textContent = ''
return
}
el.notice.hidden = false
el.notice.className = `notice ${type}`.trim()
el.notice.textContent = message
if (type === 'success') {
window.clearTimeout(setNotice._timer)
setNotice._timer = window.setTimeout(() => {
if (el.notice.textContent === message) setNotice('')
}, 1800)
}
}
function setAuthHint(message, type = '') {
el.authHint.textContent = String(message || '')
el.authHint.className = `auth-hint ${type}`.trim()
}
function applyAuthUi() {
el.authScreen.hidden = state.authenticated
el.workshopShell.hidden = !state.authenticated
el.toolTabs.hidden = !state.authenticated
el.appMain.hidden = !state.authenticated
el.btnAuthLogout.hidden = !state.authenticated
if (!state.authenticated) {
el.authPassword.value = ''
setNotice('')
}
}
async function fetchJson(url, options) {
const resp = await fetch(url, options)
let data = null
try {
data = await resp.json()
} catch {
data = null
}
if (resp.status === 401) {
state.authenticated = false
applyAuthUi()
}
if (!resp.ok || !data || data.ok === false) {
const error = data && data.error ? data.error : `http_${resp.status}`
throw new Error(error)
}
return data
}
async function refreshAuthStatus() {
try {
const data = await fetchJson('/api/token_lab/auth/status')
state.authenticated = !!data.authenticated
applyAuthUi()
} catch (err) {
state.authenticated = false
applyAuthUi()
}
}
async function handleAuthLogin() {
try {
const username = String(el.authUsername.value || '').trim()
const password = String(el.authPassword.value || '')
if (!username || !password) {
setAuthHint('请输入账号和密码', 'error')
return
}
await fetchJson('/api/token_lab/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
})
state.authenticated = true
el.authUsername.value = ''
el.authPassword.value = ''
setAuthHint('')
applyAuthUi()
setNotice('令牌工坊登录成功。', 'success')
} catch (err) {
state.authenticated = false
applyAuthUi()
setAuthHint('登录失败', 'error')
}
}
async function handleAuthLogout() {
try {
await fetchJson('/api/token_lab/auth/logout', { method: 'POST' })
state.authenticated = false
el.authUsername.value = ''
el.authPassword.value = ''
applyAuthUi()
setAuthHint('')
setNotice('令牌工坊已退出登录。', 'success')
} catch (err) {
setNotice(`退出登录失败:${err.message || err}`, 'error')
}
}
function safeJsonParse(text, fallback) {
try {
return JSON.parse(text)
} catch {
return fallback
}
}
function toDatetimeLocalValue(date) {
const pad = (n) => String(n).padStart(2, '0')
const d = new Date(date)
const yyyy = d.getFullYear()
const mm = pad(d.getMonth() + 1)
const dd = pad(d.getDate())
const hh = pad(d.getHours())
const mi = pad(d.getMinutes())
return `${yyyy}-${mm}-${dd}T${hh}:${mi}`
}
function epochFromDatetimeLocal(value) {
if (!value) return 0
const ms = new Date(value).getTime()
if (!Number.isFinite(ms)) return 0
return Math.floor(ms / 1000)
}
function formatEpoch(epochSeconds) {
if (!epochSeconds) return '-'
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(epochSeconds * 1000))
} catch {
return new Date(epochSeconds * 1000).toLocaleString()
}
}
function formatRelative(exp) {
if (!exp) return '-'
const diff = exp - Math.floor(Date.now() / 1000)
const abs = Math.abs(diff)
const days = Math.floor(abs / 86400)
const hours = Math.floor((abs % 86400) / 3600)
const mins = Math.floor((abs % 3600) / 60)
const text = `${days}天 ${hours}小时 ${mins}分钟`
return diff >= 0 ? `剩余 ${text}` : `已过期 ${text}`
}
function base64UrlFromBytes(bytes) {
let binary = ''
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
for (let i = 0; i < arr.length; i += 1) binary += String.fromCharCode(arr[i])
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
}
function bytesFromBase64Url(str) {
const normalized = String(str || '').replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - normalized.length % 4) % 4)
const binary = atob(padded)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return bytes
}
function bytesFromBase64(str) {
const binary = atob(String(str || ''))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i)
return bytes
}
function base64FromBytes(bytes) {
let binary = ''
const arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes)
for (let i = 0; i < arr.length; i += 1) binary += String.fromCharCode(arr[i])
return btoa(binary)
}
function encodeUtf8(text) {
return new TextEncoder().encode(String(text || ''))
}
function decodeJwtPart(part) {
try {
return JSON.parse(new TextDecoder().decode(bytesFromBase64Url(part)))
} catch {
return null
}
}
function extractToken(value) {
const text = String(value || '').trim()
if (!text) return ''
if (/^[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+\.[A-Za-z0-9\-_]+$/.test(text)) return text
try {
const url = new URL(text)
const token = String(url.searchParams.get('token') || '').trim()
if (token) return token
} catch {}
const match = text.match(/(?:^|[?&])token=([^\s]+)/)
return match && match[1] ? decodeURIComponent(match[1]) : ''
}
function appendTokenToUrl(baseUrl, token) {
const base = String(baseUrl || '').trim()
if (!base) return `?token=${token}`
return base.includes('?') ? `${base}&token=${token}` : `${base}?token=${token}`
}
function createKvHtml(items) {
return items.map((item) => {
const status = item.status ? ` ${item.status}` : ''
return `
${escapeHtml(item.label)}${escapeHtml(item.value)}
`
}).join('')
}
function createVerifyDebugJson(payload) {
return JSON.stringify(payload, null, 2)
}
function deriveToolAlias() {
const preset = currentPreset()
const fromName = String(el.toolPresetName.value || '').trim()
if (fromName) return fromName
if (preset && preset.name) return String(preset.name).trim()
return 'tool-token'
}
function scrollToolGenerateIntoView() {
try {
el.toolLinkOutput.scrollIntoView({ block: 'center', behavior: 'smooth' })
} catch {}
}
function escapeHtml(value) {
return String(value == null ? '' : value)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''')
}
function switchTab(name) {
el.tabButtons.forEach((btn) => btn.classList.toggle('active', btn.dataset.tab === name))
el.tabPanels.forEach((panel) => panel.classList.toggle('active', panel.dataset.panel === name))
}
function defaultPresets() {
const origin = window.location.origin || ''
return [
{
id: 'expense',
name: 'expense',
baseUrl: `${origin}/tools/expense`,
privateXml: ''
},
{
id: 'ai-lib',
name: 'ai-lib',
baseUrl: `${origin}/tools/ai-lib`,
privateXml: ''
}
]
}
function loadPresets() {
const stored = safeJsonParse(localStorage.getItem(STORAGE_PRESETS) || '', null)
const presets = Array.isArray(stored) && stored.length ? stored : defaultPresets()
state.toolPresets = presets.map((item, index) => ({
id: String(item.id || item.name || `preset-${index + 1}`),
name: String(item.name || `preset-${index + 1}`),
baseUrl: String(item.baseUrl || ''),
privateXml: String(item.privateXml || '')
}))
state.currentPresetId = localStorage.getItem(STORAGE_CURRENT_PRESET) || state.toolPresets[0].id
}
function savePresets() {
localStorage.setItem(STORAGE_PRESETS, JSON.stringify(state.toolPresets))
localStorage.setItem(STORAGE_CURRENT_PRESET, state.currentPresetId || '')
}
function currentPreset() {
return state.toolPresets.find((item) => item.id === state.currentPresetId) || null
}
function renderPresetSelect() {
el.toolPresetSelect.innerHTML = state.toolPresets.map((item) => (
``
)).join('')
el.toolPresetSelect.value = state.currentPresetId || ''
}
async function applyPreset(presetId) {
state.currentPresetId = presetId
savePresets()
renderPresetSelect()
const preset = currentPreset()
if (!preset) return
el.toolPresetName.value = preset.name
el.toolBaseUrl.value = preset.baseUrl
if (!String(el.toolAlias.value || '').trim()) el.toolAlias.value = preset.name
if (preset.privateXml) {
try {
state.toolKeys = await importKeyPairFromXml(preset.privateXml)
} catch {
state.toolKeys = null
setNotice(`预设「${preset.name}」里的私钥 XML 载入失败,请重新导入。`, 'error')
}
} else {
state.toolKeys = null
}
await refreshToolKeyUi()
}
async function saveCurrentPreset() {
let preset = currentPreset()
const name = String(el.toolPresetName.value || '').trim()
const baseUrl = String(el.toolBaseUrl.value || '').trim()
if (!name) {
setNotice('请先填写预设名称。', 'error')
return
}
let privateXml = ''
if (state.toolKeys && state.toolKeys.privateKey) privateXml = await exportPrivateXml(state.toolKeys.privateKey)
if (!preset) {
let id = name
let seq = 2
while (state.toolPresets.some((item) => item.id === id)) {
id = `${name}-${seq}`
seq += 1
}
preset = { id, name, baseUrl, privateXml }
state.toolPresets.push(preset)
state.currentPresetId = preset.id
} else {
preset.id = preset.id || name
preset.name = name
preset.baseUrl = baseUrl
preset.privateXml = privateXml
state.currentPresetId = preset.id
}
savePresets()
renderPresetSelect()
el.toolPresetSelect.value = state.currentPresetId
setNotice('当前预设已保存到本浏览器。', 'success')
}
async function startNewPreset() {
state.currentPresetId = ''
savePresets()
renderPresetSelect()
el.toolPresetSelect.value = ''
el.toolPresetName.value = ''
el.toolBaseUrl.value = ''
el.toolAlias.value = ''
el.toolPassword.value = ''
el.toolLinkOutput.value = ''
el.toolGenerateMeta.innerHTML = ''
el.toolVerifyInput.value = ''
el.toolVerifyMeta.innerHTML = ''
el.toolVerifyRaw.textContent = ''
setNotice('已切到新预设草稿。填好名称后保存,就会新增一个站点预设。', 'success')
}
function deleteCurrentPreset() {
const preset = currentPreset()
if (!preset) return
if (!window.confirm(`确定删除预设「${preset.name}」吗?`)) return
state.toolPresets = state.toolPresets.filter((item) => item.id !== preset.id)
if (!state.toolPresets.length) state.toolPresets = defaultPresets()
state.currentPresetId = state.toolPresets[0].id
savePresets()
applyPreset(state.currentPresetId)
setNotice('预设已删除。', 'success')
}
function xmlTagValue(doc, tag) {
const node = doc.querySelector(tag)
return node ? String(node.textContent || '').trim() : ''
}
function xmlToPrivateJwk(xmlText) {
const doc = new DOMParser().parseFromString(xmlText, 'application/xml')
const modulus = xmlTagValue(doc, 'Modulus')
const exponent = xmlTagValue(doc, 'Exponent')
const d = xmlTagValue(doc, 'D')
if (!modulus || !exponent || !d) throw new Error('私钥 XML 内容不完整')
const jwk = {
kty: 'RSA',
n: base64UrlFromBytes(bytesFromBase64(modulus)),
e: base64UrlFromBytes(bytesFromBase64(exponent)),
d: base64UrlFromBytes(bytesFromBase64(d)),
p: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'P'))),
q: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'Q'))),
dp: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'DP'))),
dq: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'DQ'))),
qi: base64UrlFromBytes(bytesFromBase64(xmlTagValue(doc, 'InverseQ'))),
ext: true
}
return jwk
}
function privateJwkToXml(jwk) {
const read = (name) => base64FromBytes(bytesFromBase64Url(jwk[name] || ''))
return [
'',
`${read('n')}`,
`${read('e')}`,
`${read('p')}
`,
`${read('q')}
`,
`${read('dp')}`,
`${read('dq')}`,
`${read('qi')}`,
`${read('d')}`,
''
].join('')
}
async function importKeyPairFromXml(xmlText) {
const privateJwk = xmlToPrivateJwk(xmlText)
const publicJwk = { kty: 'RSA', n: privateJwk.n, e: privateJwk.e, ext: true }
const algorithm = { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }
const privateKey = await crypto.subtle.importKey('jwk', privateJwk, algorithm, true, ['sign'])
const publicKey = await crypto.subtle.importKey('jwk', publicJwk, algorithm, true, ['verify'])
return { privateKey, publicKey }
}
async function generateKeyPair() {
return crypto.subtle.generateKey({
name: 'RSASSA-PKCS1-v1_5',
modulusLength: 2048,
publicExponent: new Uint8Array([1, 0, 1]),
hash: 'SHA-256'
}, true, ['sign', 'verify'])
}
async function exportPrivateXml(privateKey) {
const jwk = await crypto.subtle.exportKey('jwk', privateKey)
return privateJwkToXml(jwk)
}
async function exportPublicJwk(publicKey) {
return crypto.subtle.exportKey('jwk', publicKey)
}
async function fingerprintFromPublicKey(publicKey) {
const jwk = await exportPublicJwk(publicKey)
const digest = await crypto.subtle.digest('SHA-256', bytesFromBase64Url(jwk.n))
return Array.from(new Uint8Array(digest)).slice(0, 6).map((n) => n.toString(16).padStart(2, '0')).join('')
}
async function refreshToolKeyUi() {
if (!state.toolKeys || !state.toolKeys.publicKey) {
el.toolPublicJwk.value = ''
el.toolKeyMeta.innerHTML = createKvHtml([
{ label: '当前状态', value: '还没有载入私钥', status: 'bad' },
{ label: '说明', value: '先生成或导入 XML 私钥后,才能生成和验签 Tool 令牌。' }
])
return
}
const publicJwk = await exportPublicJwk(state.toolKeys.publicKey)
const fingerprint = await fingerprintFromPublicKey(state.toolKeys.publicKey)
el.toolPublicJwk.value = JSON.stringify(publicJwk, null, 2)
el.toolKeyMeta.innerHTML = createKvHtml([
{ label: '当前状态', value: '私钥已载入', status: 'good' },
{ label: '密钥指纹', value: fingerprint },
{ label: '公钥 kty', value: publicJwk.kty || '-' },
{ label: '说明', value: '保存预设后,这把私钥会只保存在当前浏览器本地。' }
])
}
async function copyText(value, successMessage) {
const text = String(value || '').trim()
if (!text) {
setNotice('没有可复制的内容。', 'error')
return
}
try {
await navigator.clipboard.writeText(text)
setNotice(successMessage || '已复制。', 'success')
} catch {
try {
const ta = document.createElement('textarea')
ta.value = text
ta.setAttribute('readonly', 'readonly')
ta.style.position = 'fixed'
ta.style.left = '-9999px'
document.body.appendChild(ta)
ta.select()
ta.setSelectionRange(0, ta.value.length)
const ok = document.execCommand('copy')
document.body.removeChild(ta)
if (!ok) throw new Error('copy_failed')
setNotice(successMessage || '已复制。', 'success')
} catch {
setNotice('复制失败,请手动选中复制。', 'error')
}
}
}
async function handleNavGenerate() {
try {
const exp = epochFromDatetimeLocal(el.navExpiry.value)
const baseUrl = String(el.navBaseUrl.value || '').trim()
const data = await fetchJson('/api/token_lab/nav/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ baseUrl, exp })
})
el.navLinkOutput.value = data.link || ''
el.navGenerateMeta.innerHTML = createKvHtml([
{ label: 'iss', value: data.payload.iss || '-' },
{ label: 'iat(北京时间)', value: formatEpoch(data.payload.iat) },
{ label: 'exp(北京时间)', value: formatEpoch(data.payload.exp) },
{ label: '剩余时长', value: formatRelative(data.payload.exp), status: 'good' },
{ label: 'jti', value: data.payload.jti || '-' },
{ label: '基础地址', value: baseUrl || '/' }
])
setNotice('首页导航令牌已生成。', 'success')
} catch (err) {
setNotice(`生成首页令牌失败:${err.message || err}`, 'error')
}
}
async function handleNavVerify() {
try {
const value = String(el.navVerifyInput.value || '').trim()
const data = await fetchJson('/api/token_lab/nav/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value })
})
const payload = data.payload || {}
const result = data.result || {}
el.navVerifyMeta.innerHTML = createKvHtml([
{ label: '类型', value: '首页导航 token' },
{ label: '签名验证', value: result.sig_ok ? '通过' : '未通过', status: result.sig_ok ? 'good' : 'bad' },
{ label: '整体状态', value: result.ok ? '可用' : `不可用(${result.reason || '校验失败'})`, status: result.ok ? 'good' : 'bad' },
{ label: 'iss', value: payload.iss || '-' },
{ label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
{ label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
{ label: '剩余时长', value: formatRelative(payload.exp), status: result.ok ? 'good' : 'bad' },
{ label: 'jti', value: payload.jti || '-' }
])
el.navVerifyRaw.textContent = createVerifyDebugJson({
header: data.header || null,
payload,
result: {
ok: !!result.ok,
sig_ok: !!result.sig_ok,
reason: result.reason || '',
matched_source: result.matched_source || '',
checked_keys: result.checked_keys || 0,
keys_sources: Array.isArray(result.keys_sources) ? result.keys_sources : []
}
})
setNotice('首页令牌验证完成。', 'success')
} catch (err) {
setNotice(`验证首页令牌失败:${err.message || err}`, 'error')
}
}
async function sha256Base64Url(text) {
const digest = await crypto.subtle.digest('SHA-256', encodeUtf8(text))
return base64UrlFromBytes(new Uint8Array(digest))
}
async function handleToolGenerate() {
try {
if (!state.toolKeys || !state.toolKeys.privateKey) throw new Error('请先生成或导入私钥 XML')
const alias = String(el.toolAlias.value || '').trim() || deriveToolAlias()
const baseUrl = String(el.toolBaseUrl.value || '').trim()
const exp = epochFromDatetimeLocal(el.toolExpiry.value)
el.toolAlias.value = alias
if (!baseUrl) {
el.toolBaseUrl.focus()
throw new Error('请先填写基础地址')
}
if (!exp) {
el.toolExpiry.focus()
throw new Error('请先填写过期时间')
}
const now = Math.floor(Date.now() / 1000)
if (exp <= now) throw new Error('过期时间必须晚于当前时间')
const payload = {
alias,
iat: now,
exp,
jti: crypto.randomUUID().replace(/-/g, ''),
pwd: await sha256Base64Url(String(el.toolPassword.value || ''))
}
const header = { alg: 'RS256', typ: 'JWT' }
const data = `${base64UrlFromBytes(encodeUtf8(JSON.stringify(header)))}.${base64UrlFromBytes(encodeUtf8(JSON.stringify(payload)))}`
const signature = await crypto.subtle.sign('RSASSA-PKCS1-v1_5', state.toolKeys.privateKey, encodeUtf8(data))
const token = `${data}.${base64UrlFromBytes(new Uint8Array(signature))}`
const link = appendTokenToUrl(baseUrl, token)
el.toolLinkOutput.value = link
el.toolGenerateMeta.innerHTML = createKvHtml([
{ label: 'alias', value: payload.alias },
{ label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
{ label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
{ label: '剩余时长', value: formatRelative(payload.exp), status: 'good' },
{ label: 'jti', value: payload.jti },
{ label: 'pwd 字段', value: payload.pwd ? '已生成' : '空' }
])
scrollToolGenerateIntoView()
setNotice('Tool 令牌已在浏览器本地生成。', 'success')
} catch (err) {
el.toolGenerateMeta.innerHTML = createKvHtml([
{ label: '生成失败', value: String(err.message || err), status: 'bad' }
])
scrollToolGenerateIntoView()
setNotice(`生成 Tool 令牌失败:${err.message || err}`, 'error')
}
}
async function handleToolVerify() {
try {
const token = extractToken(el.toolVerifyInput.value)
if (!token) throw new Error('没有识别到 token')
const parts = token.split('.')
if (parts.length !== 3) throw new Error('token 格式不正确')
const header = decodeJwtPart(parts[0]) || {}
const payload = decodeJwtPart(parts[1]) || {}
let signatureLabel = '未载入当前密钥'
let signatureStatus = ''
let signatureOk = null
if (state.toolKeys && state.toolKeys.publicKey) {
signatureOk = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
state.toolKeys.publicKey,
bytesFromBase64Url(parts[2]),
encodeUtf8(`${parts[0]}.${parts[1]}`)
)
signatureLabel = signatureOk ? '通过' : '未通过'
signatureStatus = signatureOk ? 'good' : 'bad'
}
el.toolVerifyMeta.innerHTML = createKvHtml([
{ label: '类型', value: payload.alias || payload.pwd ? 'Tool token' : '未知格式' },
{ label: '签名验证', value: signatureLabel, status: signatureStatus },
{ label: 'alias', value: payload.alias || '-' },
{ label: 'iat(北京时间)', value: formatEpoch(payload.iat) },
{ label: 'exp(北京时间)', value: formatEpoch(payload.exp) },
{ label: '剩余时长', value: formatRelative(payload.exp), status: payload.exp && payload.exp >= Math.floor(Date.now() / 1000) ? 'good' : 'bad' },
{ label: 'jti', value: payload.jti || '-' },
{ label: 'pwd 字段', value: payload.pwd ? '存在' : '不存在' }
])
el.toolVerifyRaw.textContent = createVerifyDebugJson({
header,
payload,
result: {
signature_ok: signatureOk,
key_loaded: !!(state.toolKeys && state.toolKeys.publicKey)
}
})
setNotice('Tool 令牌验证完成。', 'success')
} catch (err) {
setNotice(`验证 Tool 令牌失败:${err.message || err}`, 'error')
}
}
async function handleImportXmlFile(file) {
if (!file) return
const text = await file.text()
state.toolKeys = await importKeyPairFromXml(text)
await refreshToolKeyUi()
setNotice('XML 私钥已导入。记得按一下“保存当前预设”。', 'success')
}
function downloadText(filename, content) {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
function bindEvents() {
el.tabButtons.forEach((btn) => {
btn.addEventListener('click', () => switchTab(btn.dataset.tab))
})
$('#btnNavGenerate').addEventListener('click', handleNavGenerate)
$('#btnNavCopy').addEventListener('click', () => copyText(el.navLinkOutput.value, '首页链接已复制。'))
$('#btnNavVerify').addEventListener('click', handleNavVerify)
$('#btnAuthLogin').addEventListener('click', handleAuthLogin)
el.authUsername.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleAuthLogin()
})
el.authPassword.addEventListener('keydown', (e) => {
if (e.key === 'Enter') handleAuthLogin()
})
el.btnAuthLogout.addEventListener('click', handleAuthLogout)
el.toolPresetSelect.addEventListener('change', (e) => applyPreset(e.target.value))
$('#btnToolSavePreset').addEventListener('click', saveCurrentPreset)
$('#btnToolDeletePreset').addEventListener('click', deleteCurrentPreset)
$('#btnToolGenerateKey').addEventListener('click', async () => {
state.toolKeys = await generateKeyPair()
await refreshToolKeyUi()
setNotice('新的 RSA 密钥已生成。记得按一下“保存当前预设”。', 'success')
})
$('#btnToolImportXml').addEventListener('click', () => el.toolXmlFile.click())
el.toolXmlFile.addEventListener('change', async (e) => {
try {
await handleImportXmlFile(e.target.files && e.target.files[0])
} catch (err) {
setNotice(`导入 XML 失败:${err.message || err}`, 'error')
} finally {
e.target.value = ''
}
})
$('#btnToolExportXml').addEventListener('click', async () => {
try {
if (!state.toolKeys || !state.toolKeys.privateKey) throw new Error('当前没有可导出的私钥')
const xml = await exportPrivateXml(state.toolKeys.privateKey)
const filename = `${String(el.toolPresetName.value || 'tool-token').trim() || 'tool-token'}_private_key.xml`
downloadText(filename, xml)
setNotice('私钥 XML 已导出。', 'success')
} catch (err) {
setNotice(`导出 XML 失败:${err.message || err}`, 'error')
}
})
$('#btnToolNewPreset').addEventListener('click', startNewPreset)
$('#btnToolCopyJwk').addEventListener('click', () => copyText(el.toolPublicJwk.value, '公钥 JWK 已复制。'))
$('#btnToolGenerateToken').addEventListener('click', handleToolGenerate)
$('#btnToolCopyLink').addEventListener('click', () => copyText(el.toolLinkOutput.value, 'Tool 链接已复制。'))
$('#btnToolVerify').addEventListener('click', handleToolVerify)
}
async function init() {
loadPresets()
renderPresetSelect()
bindEvents()
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)
await applyPreset(state.currentPresetId)
await refreshAuthStatus()
}
init().catch((err) => {
setNotice(`初始化失败:${err.message || err}`, 'error')
})
})()