fix: render OTP QR code reliably

This commit is contained in:
jxxghp
2026-05-25 23:07:45 +08:00
parent cacc2602df
commit 8a12ecf918
+69 -24
View File
@@ -41,42 +41,69 @@ const otpPassword = ref('')
const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true) const allowPasskeyWithoutOtp = computed(() => globalSettingsStore.get('PASSKEY_ALLOW_REGISTER_WITHOUT_OTP') === true)
// OTP 初始化加载状态
const otpLoading = ref(false)
// OTP 初始化失败信息
const otpGenerateError = ref('')
// 二维码图片 base64 // 二维码图片 base64
const qrCodeImage = ref('') const qrCodeImage = ref('')
// 二维码信息 // 二维码信息
const qrCode = ref('') const qrCode = ref('')
// 为当前用户获取Otp Uri // 清空当前 OTP 设置流程的临时数据。
function resetOtpSetupState() {
qrCodeImage.value = ''
qrCode.value = ''
otpUri.value = ''
secret.value = ''
otpGenerateError.value = ''
}
// 标记 OTP 初始化失败,并向用户显示明确错误。
function setOtpGenerateError(message?: string) {
const errorMessage = message || t('common.error')
otpGenerateError.value = t('profile.otpGenerateFailed', { message: errorMessage })
$toast.error(otpGenerateError.value)
}
// 为当前用户获取 OTP URI 并生成二维码图片。
async function getOtpUri() { async function getOtpUri() {
resetOtpSetupState()
// 如果已经启用OTP,只打开对话框,不生成新的二维码 // 如果已经启用OTP,只打开对话框,不生成新的二维码
if (props.isOtp) { if (props.isOtp) {
qrCode.value = '' // 清空二维码,这样对话框会显示清除界面
qrCodeImage.value = ''
return return
} }
// 未启用OTP,生成新的二维码 // 未启用OTP,生成新的二维码
otpLoading.value = true
try { try {
const result = (await api.post('mfa/otp/generate')) as ApiResponse<{ const result = (await api.post('mfa/otp/generate')) as ApiResponse<{
uri: string uri: string
secret: string secret: string
}> }>
if (result.success) { const uri = result.data?.uri?.trim()
otpUri.value = result.data.uri const otpSecret = result.data?.secret?.trim()
secret.value = result.data.secret
qrCode.value = result.data.uri if (result.success && uri) {
otpUri.value = uri
secret.value = otpSecret || ''
qrCode.value = uri
// 生成二维码图片 // 生成二维码图片
qrCodeImage.value = await QRCode.toDataURL(result.data.uri, { qrCodeImage.value = await QRCode.toDataURL(uri, {
width: 200, width: 200,
margin: 1, margin: 1,
}) })
} else { } else {
$toast.error(t('profile.otpGenerateFailed', { message: result.message })) setOtpGenerateError(result.message || 'empty otp uri')
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
$toast.error(t('profile.otpGenerateFailed', { message: error instanceof Error ? error.message : String(error) })) setOtpGenerateError(error instanceof Error ? error.message : String(error))
} finally {
otpLoading.value = false
} }
} }
@@ -145,10 +172,8 @@ watch(
otpPassword.value = '' otpPassword.value = ''
} else { } else {
// 弹窗关闭时,清空数据 // 弹窗关闭时,清空数据
qrCodeImage.value = '' resetOtpSetupState()
qrCode.value = '' otpLoading.value = false
otpUri.value = ''
secret.value = ''
otpPassword.value = '' otpPassword.value = ''
} }
}, },
@@ -194,16 +219,29 @@ watch(
<!-- 设置新的OTP --> <!-- 设置新的OTP -->
<template v-else> <template v-else>
<div class="my-6 rounded text-center p-3 border" style="width: fit-content; margin: 0 auto"> <div
<VImg class="mx-auto" :src="qrCodeImage" width="200" height="200"> class="my-6 rounded text-center p-3 border d-flex align-center justify-center"
<template #placeholder> style="width: 226px; height: 226px; margin: 0 auto"
<div class="w-full h-full"> >
<VSkeletonLoader class="object-cover aspect-w-1 aspect-h-1" /> <img
</div> v-if="qrCodeImage"
</template> class="mx-auto d-block otp-qrcode-image"
</VImg> :src="qrCodeImage"
:alt="t('profile.setupAuthenticator')"
width="200"
height="200"
/>
<VProgressCircular v-else-if="otpLoading" indeterminate color="primary" />
<div v-else class="w-100">
<VAlert type="error" variant="tonal" density="compact" class="mb-3">
{{ otpGenerateError || t('profile.otpGenerateFailed', { message: t('common.error') }) }}
</VAlert>
<VBtn size="small" variant="tonal" prepend-icon="mdi-refresh" @click="getOtpUri">
{{ t('common.retry') }}
</VBtn>
</div>
</div> </div>
<VAlert :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')"> <VAlert v-if="secret" :title="secret" variant="tonal" type="warning" class="my-4" :text="t('profile.secretKeyTip')">
<template #prepend /> <template #prepend />
</VAlert> </VAlert>
<VForm @submit.prevent="judgeOtpPassword"> <VForm @submit.prevent="judgeOtpPassword">
@@ -221,7 +259,7 @@ watch(
<VBtn variant="outlined" color="secondary" @click="show = false"> <VBtn variant="outlined" color="secondary" @click="show = false">
{{ t('common.cancel') }} {{ t('common.cancel') }}
</VBtn> </VBtn>
<VBtn type="submit"> <VBtn type="submit" :disabled="!otpUri || otpLoading">
<template #prepend> <template #prepend>
<VIcon icon="mdi-check" /> <VIcon icon="mdi-check" />
</template> </template>
@@ -234,3 +272,10 @@ watch(
</VCard> </VCard>
</VDialog> </VDialog>
</template> </template>
<style scoped>
.otp-qrcode-image {
inline-size: 200px;
block-size: 200px;
}
</style>