fix(frontend): refine token refresh controls

This commit is contained in:
2026-05-05 16:23:24 +08:00
parent 24bc9448a0
commit 745ffb1353
4 changed files with 134 additions and 8 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "node --test --experimental-strip-types src/app/theme.test.ts src/components/templates/template-config.test.ts", "test": "node --test --experimental-strip-types src/app/theme.test.ts src/components/templates/template-config.test.ts src/views/dashboard-license.test.ts",
"typecheck": "vue-tsc -b", "typecheck": "vue-tsc -b",
"lint": "eslint . --fix", "lint": "eslint . --fix",
"lint:check": "eslint .", "lint:check": "eslint .",
+28 -7
View File
@@ -24,6 +24,7 @@ import { useRouter } from '@/app/router'
import StateBlock from '@/components/StateBlock.vue' import StateBlock from '@/components/StateBlock.vue'
import { alertClass, cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui' import { alertClass, cardClass, inputClass, sectionHeaderClass, toneClass } from '@/components/ui'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { import {
cronLabel, cronLabel,
extractErrorMessage, extractErrorMessage,
@@ -31,6 +32,11 @@ import {
statusLabel, statusLabel,
statusTone, statusTone,
} from '@/utils/format' } from '@/utils/format'
import {
canRefreshAuthorization,
formatAuthorizationExpiryTooltip,
formatRemainingDays,
} from './dashboard-license'
const router = useRouter() const router = useRouter()
const auth = useAuth() const auth = useAuth()
@@ -71,6 +77,9 @@ const tokenDetail = computed(() => {
if (tokenStatus.value.expiring_soon) return 'Token 即将过期,建议准备刷新授权。' if (tokenStatus.value.expiring_soon) return 'Token 即将过期,建议准备刷新授权。'
return `业务 Token 正常,${tokenStatus.value.days_until_expiry ?? '未知'} 天后过期。` return `业务 Token 正常,${tokenStatus.value.days_until_expiry ?? '未知'} 天后过期。`
}) })
const remainingDaysLabel = computed(() => formatRemainingDays(tokenStatus.value?.days_until_expiry))
const expiryTooltip = computed(() => formatAuthorizationExpiryTooltip(tokenStatus.value))
const canRefreshToken = computed(() => canRefreshAuthorization(tokenStatus.value))
const needsEmail = computed(() => !auth.state.user?.email) const needsEmail = computed(() => !auth.state.user?.email)
const needsPassword = computed(() => auth.state.user?.has_password === false) const needsPassword = computed(() => auth.state.user?.has_password === false)
@@ -295,12 +304,23 @@ onMounted(load)
<div class="grid gap-3 p-4 text-sm"> <div class="grid gap-3 p-4 text-sm">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-muted-foreground">剩余</span> <span class="text-muted-foreground">剩余</span>
<span class="font-medium"> <TooltipProvider v-if="expiryTooltip">
{{ <Tooltip>
tokenStatus?.days_until_expiry == null <TooltipTrigger as-child>
? '未知' <span
: `${tokenStatus.days_until_expiry} 天` class="cursor-help font-medium underline decoration-muted-foreground/40 underline-offset-4"
}} :title="expiryTooltip"
>
{{ remainingDaysLabel }}
</span>
</TooltipTrigger>
<TooltipContent>
{{ expiryTooltip }}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span v-else class="font-medium">
{{ remainingDaysLabel }}
</span> </span>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
@@ -309,7 +329,8 @@ onMounted(load)
</div> </div>
<div class="text-sm text-muted-foreground">{{ tokenDetail }}</div> <div class="text-sm text-muted-foreground">{{ tokenDetail }}</div>
<Button <Button
:variant="tokenStatus?.is_valid ? 'outline' : 'default'" :variant="canRefreshToken ? 'default' : 'outline'"
:disabled="!canRefreshToken"
type="button" type="button"
@click="router.navigate('/login')" @click="router.navigate('/login')"
> >
@@ -0,0 +1,67 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import type { TokenStatus } from '@/api'
import {
canRefreshAuthorization,
formatAuthorizationExpiryTooltip,
formatRemainingDays,
} from './dashboard-license.ts'
test('formats authorization expiry tooltip with concrete expiration time', () => {
const token: TokenStatus = {
is_valid: true,
jwt_exp: '',
expires_at: Date.UTC(2026, 4, 6, 16, 30, 12) / 1000,
days_until_expiry: 0,
expiring_soon: true,
}
assert.equal(
formatAuthorizationExpiryTooltip(token, 'zh-CN', 'UTC'),
'过期时间:2026-05-06 16:30:12',
)
})
test('omits authorization expiry tooltip when expiration timestamp is missing', () => {
assert.equal(formatAuthorizationExpiryTooltip(null), null)
assert.equal(
formatAuthorizationExpiryTooltip({
is_valid: false,
jwt_exp: '',
expires_at: null,
days_until_expiry: null,
expiring_soon: false,
}),
null,
)
})
test('allows QR refresh only for expired or invalid authorization', () => {
assert.equal(canRefreshAuthorization(null), false)
assert.equal(
canRefreshAuthorization({
is_valid: true,
jwt_exp: '',
expires_at: 1_778_111_412,
days_until_expiry: 0,
expiring_soon: true,
}),
false,
)
assert.equal(
canRefreshAuthorization({
is_valid: false,
jwt_exp: '',
expires_at: null,
days_until_expiry: null,
expiring_soon: false,
}),
true,
)
})
test('formats remaining days label consistently', () => {
assert.equal(formatRemainingDays(null), '未知')
assert.equal(formatRemainingDays(0), '0 天')
assert.equal(formatRemainingDays(12), '12 天')
})
@@ -0,0 +1,38 @@
import type { TokenStatus } from '@/api'
export function formatRemainingDays(days?: number | null) {
return days == null ? '未知' : `${days}`
}
export function canRefreshAuthorization(token?: TokenStatus | null) {
return token?.is_valid === false
}
export function formatAuthorizationExpiryTooltip(
token?: TokenStatus | null,
_locale?: string,
timeZone?: string,
) {
if (!token?.expires_at) return null
const expiresAt = new Date(token.expires_at * 1000)
if (Number.isNaN(expiresAt.getTime())) return null
const parts = new Intl.DateTimeFormat('en-CA', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
timeZone,
})
.formatToParts(expiresAt)
.reduce<Record<string, string>>((values, part) => {
if (part.type !== 'literal') values[part.type] = part.value
return values
}, {})
return `过期时间:${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second}`
}