132 lines
4.9 KiB
Vue
132 lines
4.9 KiB
Vue
<script setup lang="ts">
|
||
import { computed, ref } from 'vue'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Badge } from '@/components/ui/badge'
|
||
import ResultArea from '../components/ResultArea.vue'
|
||
|
||
const token = ref('')
|
||
const error = ref('')
|
||
|
||
function base64UrlDecode(s: string): string {
|
||
const cleaned = s.replace(/-/g, '+').replace(/_/g, '/')
|
||
const pad = cleaned.length % 4
|
||
const normalized = pad ? cleaned + '='.repeat(4 - pad) : cleaned
|
||
const bin = atob(normalized)
|
||
const bytes = Uint8Array.from(bin, ch => ch.charCodeAt(0))
|
||
return new TextDecoder().decode(bytes)
|
||
}
|
||
|
||
interface TimeClaim {
|
||
name: string
|
||
value: string
|
||
local: string
|
||
status: 'ok' | 'expired' | 'not-yet' | 'unknown'
|
||
}
|
||
|
||
const parsed = computed<{
|
||
header: string
|
||
payload: string
|
||
signature: string
|
||
timeClaims: TimeClaim[]
|
||
} | null>(() => {
|
||
error.value = ''
|
||
const t = token.value.trim()
|
||
if (!t) return null
|
||
const parts = t.split('.')
|
||
if (parts.length < 2) {
|
||
error.value = 'JWT 格式不正确(应为 header.payload.signature)'
|
||
return null
|
||
}
|
||
try {
|
||
const header = formatJson(base64UrlDecode(parts[0]))
|
||
const payload = formatJson(base64UrlDecode(parts[1]))
|
||
const signature = parts[2] ?? ''
|
||
return { header, payload, signature, timeClaims: parseTimeClaims(payload) }
|
||
} catch (e) {
|
||
error.value = '解码失败:' + String(e)
|
||
return null
|
||
}
|
||
})
|
||
|
||
function formatJson(s: string): string {
|
||
let pretty = s
|
||
try {
|
||
pretty = JSON.stringify(JSON.parse(s), null, 2)
|
||
} catch {
|
||
/* 非 JSON(如已损毁),原样展示 */
|
||
}
|
||
return pretty
|
||
}
|
||
|
||
/** 解读 iat / nbf / exp 等时间声明(秒级时间戳) */
|
||
function parseTimeClaims(payload: string): TimeClaim[] {
|
||
let obj: unknown
|
||
try {
|
||
obj = JSON.parse(payload)
|
||
} catch {
|
||
return []
|
||
}
|
||
if (!obj || typeof obj !== 'object') return []
|
||
const p = obj as Record<string, unknown>
|
||
const claims: TimeClaim[] = []
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const NAMES: Record<string, string> = { iat: 'iat(签发时间)', nbf: 'nbf(生效时间)', exp: 'exp(过期时间)' }
|
||
const pad = (x: number) => String(x).padStart(2, '0')
|
||
for (const key of ['iat', 'nbf', 'exp']) {
|
||
const v = p[key]
|
||
if (typeof v !== 'number' || !Number.isFinite(v)) continue
|
||
const d = new Date(v * 1000)
|
||
if (Number.isNaN(d.getTime())) continue
|
||
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||
let status: TimeClaim['status'] = 'ok'
|
||
if (key === 'exp' && v < now) status = 'expired'
|
||
else if (key === 'nbf' && v > now) status = 'not-yet'
|
||
else if (key === 'iat' && v > now + 60) status = 'unknown'
|
||
claims.push({ name: NAMES[key], value: String(v), local, status })
|
||
}
|
||
return claims
|
||
}
|
||
|
||
const STATUS_BADGE: Record<TimeClaim['status'], { label: string; class: string } | null> = {
|
||
ok: null,
|
||
expired: { label: '已过期', class: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/30' },
|
||
'not-yet': { label: '尚未生效', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' },
|
||
unknown: { label: '签发时间在未来(时钟偏差?)', class: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/30' }
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="flex flex-col gap-4">
|
||
<div class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">JWT Token</Label>
|
||
<Textarea
|
||
v-model="token"
|
||
placeholder="eyJhbGciOi...(header.payload.signature)"
|
||
class="min-h-[70px] font-mono text-xs resize-y break-all"
|
||
/>
|
||
</div>
|
||
|
||
<template v-if="parsed">
|
||
<ResultArea :text="parsed.header" label="Header" placeholder="Header" />
|
||
<ResultArea :text="parsed.payload" label="Payload(载荷)" placeholder="Payload" />
|
||
<ResultArea :text="parsed.signature" label="Signature(签名)" placeholder="Signature" />
|
||
|
||
<div v-if="parsed.timeClaims.length > 0" class="flex flex-col gap-1.5">
|
||
<Label class="text-xs">时间声明解读</Label>
|
||
<div class="rounded-md border border-border divide-y divide-border text-xs">
|
||
<div v-for="c in parsed.timeClaims" :key="c.name" class="flex items-center gap-3 px-3 py-2 flex-wrap">
|
||
<span class="font-mono text-muted-foreground">{{ c.name }}</span>
|
||
<span class="font-mono">{{ c.local }}</span>
|
||
<Badge v-if="STATUS_BADGE[c.status]" variant="outline" :class="['py-0 text-[10px] ml-auto', STATUS_BADGE[c.status]!.class]">
|
||
{{ STATUS_BADGE[c.status]!.label }}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
<p v-else-if="!token" class="text-xs text-muted-foreground">在左侧粘贴 JWT,下方将自动解析 Header 与 Payload。</p>
|
||
<p v-else class="text-xs text-destructive">{{ error }}</p>
|
||
</div>
|
||
</template>
|