(() => { 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') }) })()