优化密码找回流程、管理后台批量操作和上传体验 #2

Merged
Mplan merged 9 commits from fix into main 2026-05-31 17:00:16 +08:00
Showing only changes of commit 08aafeffcb - Show all commits
+80 -32
View File
@@ -1,27 +1,50 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui' import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
import { createClient } from '@supabase/supabase-js' import { supabase } from '@/lib/supabase'
const router = useRouter() const router = useRouter()
const password = ref('') const password = ref('')
const confirmPassword = ref('') const confirmPassword = ref('')
const error = ref('') const error = ref('')
const success = ref(false) const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
const loading = ref(false) const loading = ref(false)
const countdown = ref(5)
let countdownTimer: ReturnType<typeof setInterval> | null = null
const canSubmit = computed(() => password.value.length >= 6 && password.value === confirmPassword.value) const canSubmit = computed(() =>
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
)
function getRecoveryTokens() { function showInvalidRecoveryLink() {
const hash = new URLSearchParams(window.location.hash.slice(1)) error.value = '密码重置链接无效或已过期,请重新发送邮件。'
return { state.value = 'invalid'
accessToken: hash.get('access_token'), }
refreshToken: hash.get('refresh_token'),
type: hash.get('type'), function getResetErrorMessage(value: unknown) {
error: hash.get('error'), const message = value instanceof Error ? value.message : ''
if (
message.includes('Auth session missing')
|| message.includes('session_not_found')
|| message.includes('refresh_token_not_found')
|| message.includes('Invalid Refresh Token')
) {
return '密码重置链接无效或已过期,请重新发送邮件。'
} }
return message || '密码重置失败,请稍后重试。'
}
function startCountdown() {
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
if (countdownTimer) clearInterval(countdownTimer)
router.push('/login')
}
}, 1000)
} }
async function handleResetPassword() { async function handleResetPassword() {
@@ -36,37 +59,45 @@ async function handleResetPassword() {
return return
} }
const { accessToken, refreshToken, type, error: recoveryError } = getRecoveryTokens() const { data: { session }, error: sessionError } = await supabase.auth.getSession()
if (recoveryError || type !== 'recovery' || !accessToken || !refreshToken) { if (sessionError || !session?.user) {
error.value = '密码重置链接无效或已过期,请重新发送邮件。' showInvalidRecoveryLink()
return return
} }
loading.value = true loading.value = true
try { try {
const resetClient = createClient( const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY,
)
const { error: sessionError } = await resetClient.auth.setSession({
access_token: accessToken,
refresh_token: refreshToken,
})
if (sessionError) throw sessionError
const { error: updateError } = await resetClient.auth.updateUser({ password: password.value })
if (updateError) throw updateError if (updateError) throw updateError
await resetClient.auth.signOut() await supabase.auth.signOut()
window.history.replaceState(null, '', window.location.pathname) window.history.replaceState(null, '', window.location.pathname)
success.value = true countdown.value = 5
state.value = 'success'
startCountdown()
} catch (e) { } catch (e) {
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试。' error.value = getResetErrorMessage(e)
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
state.value = 'invalid'
}
} finally { } finally {
loading.value = false loading.value = false
} }
} }
onMounted(async () => {
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
if (sessionError || !session?.user) {
showInvalidRecoveryLink()
return
}
state.value = 'ready'
})
onUnmounted(() => {
if (countdownTimer) clearInterval(countdownTimer)
})
</script> </script>
<template> <template>
@@ -74,21 +105,38 @@ async function handleResetPassword() {
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl">
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]"> <NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult <NResult
v-if="success" v-if="state === 'success'"
status="success" status="success"
title="密码已重置" title="密码已重置"
description="现在可以使用新密码登录。" description="现在可以使用新密码登录。"
> >
<template #footer> <template #footer>
<div class="space-y-4">
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">返回登录</NButton> <NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">返回登录</NButton>
</div>
</template> </template>
</NResult> </NResult>
<template v-else-if="state === 'checking'">
<div class="flex flex-col items-center justify-center py-12 text-center">
<NSpin size="large" />
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证重置链接...</h1>
<p class="mt-2 text-slate-500">请稍候</p>
</div>
</template>
<template v-else> <template v-else>
<div class="mb-8"> <div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div> <div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1> <h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
<p class="mt-2 text-sm text-slate-500">请输入新的登录密码提交后当前重置链接会失效</p> <p class="mt-2 text-sm text-slate-500">
{{
state === 'invalid'
? '当前重置链接不可用,请重新发送密码重置邮件。'
: '请输入新的登录密码,提交后当前重置链接会失效。'
}}
</p>
</div> </div>
<NForm @submit.prevent="handleResetPassword"> <NForm @submit.prevent="handleResetPassword">