| <template> |
| <AuthLayout> |
| <div class="space-y-6"> |
| |
| <div class="text-center"> |
| <h2 class="text-2xl font-bold text-gray-900 dark:text-white"> |
| {{ t('auth.createAccount') }} |
| </h2> |
| <p class="mt-2 text-sm text-gray-500 dark:text-dark-400"> |
| {{ t('auth.signUpToStart', { siteName }) }} |
| </p> |
| </div> |
| |
| |
| <LinuxDoOAuthSection v-if="linuxdoOAuthEnabled" :disabled="isLoading" /> |
| |
| |
| <div |
| v-if="!registrationEnabled && settingsLoaded" |
| class="rounded-xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-800/50 dark:bg-amber-900/20" |
| > |
| <div class="flex items-start gap-3"> |
| <div class="flex-shrink-0"> |
| <Icon name="exclamationCircle" size="md" class="text-amber-500" /> |
| </div> |
| <p class="text-sm text-amber-700 dark:text-amber-400"> |
| {{ t('auth.registrationDisabled') }} |
| </p> |
| </div> |
| </div> |
| |
| |
| <form v-else @submit.prevent="handleRegister" class="space-y-5"> |
| |
| <div> |
| <label for="email" class="input-label"> |
| {{ t('auth.emailLabel') }} |
| </label> |
| <div class="relative"> |
| <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"> |
| <Icon name="mail" size="md" class="text-gray-400 dark:text-dark-500" /> |
| </div> |
| <input |
| id="email" |
| v-model="formData.email" |
| type="email" |
| required |
| autofocus |
| autocomplete="email" |
| :disabled="isLoading" |
| class="input pl-11" |
| :class="{ 'input-error': errors.email }" |
| :placeholder="t('auth.emailPlaceholder')" |
| /> |
| </div> |
| <p v-if="errors.email" class="input-error-text"> |
| {{ errors.email }} |
| </p> |
| </div> |
| |
| |
| <div> |
| <label for="password" class="input-label"> |
| {{ t('auth.passwordLabel') }} |
| </label> |
| <div class="relative"> |
| <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"> |
| <Icon name="lock" size="md" class="text-gray-400 dark:text-dark-500" /> |
| </div> |
| <input |
| id="password" |
| v-model="formData.password" |
| :type="showPassword ? 'text' : 'password'" |
| required |
| autocomplete="new-password" |
| :disabled="isLoading" |
| class="input pl-11 pr-11" |
| :class="{ 'input-error': errors.password }" |
| :placeholder="t('auth.createPasswordPlaceholder')" |
| /> |
| <button |
| type="button" |
| @click="showPassword = !showPassword" |
| class="absolute inset-y-0 right-0 flex items-center pr-3.5 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-dark-300" |
| > |
| <Icon v-if="showPassword" name="eyeOff" size="md" /> |
| <Icon v-else name="eye" size="md" /> |
| </button> |
| </div> |
| <p v-if="errors.password" class="input-error-text"> |
| {{ errors.password }} |
| </p> |
| <p v-else class="input-hint"> |
| {{ t('auth.passwordHint') }} |
| </p> |
| </div> |
| |
| |
| <div v-if="invitationCodeEnabled"> |
| <label for="invitation_code" class="input-label"> |
| {{ t('auth.invitationCodeLabel') }} |
| </label> |
| <div class="relative"> |
| <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"> |
| <Icon name="key" size="md" :class="invitationValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" /> |
| </div> |
| <input |
| id="invitation_code" |
| v-model="formData.invitation_code" |
| type="text" |
| :disabled="isLoading" |
| class="input pl-11 pr-10" |
| :class="{ |
| 'border-green-500 focus:border-green-500 focus:ring-green-500': invitationValidation.valid, |
| 'border-red-500 focus:border-red-500 focus:ring-red-500': invitationValidation.invalid || errors.invitation_code |
| }" |
| :placeholder="t('auth.invitationCodePlaceholder')" |
| @input="handleInvitationCodeInput" |
| /> |
| |
| <div v-if="invitationValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5"> |
| <svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| </div> |
| <div v-else-if="invitationValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5"> |
| <Icon name="checkCircle" size="md" class="text-green-500" /> |
| </div> |
| <div v-else-if="invitationValidation.invalid || errors.invitation_code" class="absolute inset-y-0 right-0 flex items-center pr-3.5"> |
| <Icon name="exclamationCircle" size="md" class="text-red-500" /> |
| </div> |
| </div> |
| |
| <transition name="fade"> |
| <div v-if="invitationValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20"> |
| <Icon name="checkCircle" size="sm" class="text-green-600 dark:text-green-400" /> |
| <span class="text-sm text-green-700 dark:text-green-400"> |
| {{ t('auth.invitationCodeValid') }} |
| </span> |
| </div> |
| <p v-else-if="invitationValidation.invalid" class="input-error-text"> |
| {{ invitationValidation.message }} |
| </p> |
| <p v-else-if="errors.invitation_code" class="input-error-text"> |
| {{ errors.invitation_code }} |
| </p> |
| </transition> |
| </div> |
| |
| |
| <div v-if="promoCodeEnabled"> |
| <label for="promo_code" class="input-label"> |
| {{ t('auth.promoCodeLabel') }} |
| <span class="ml-1 text-xs font-normal text-gray-400 dark:text-dark-500">({{ t('common.optional') }})</span> |
| </label> |
| <div class="relative"> |
| <div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3.5"> |
| <Icon name="gift" size="md" :class="promoValidation.valid ? 'text-green-500' : 'text-gray-400 dark:text-dark-500'" /> |
| </div> |
| <input |
| id="promo_code" |
| v-model="formData.promo_code" |
| type="text" |
| :disabled="isLoading" |
| class="input pl-11 pr-10" |
| :class="{ |
| 'border-green-500 focus:border-green-500 focus:ring-green-500': promoValidation.valid, |
| 'border-red-500 focus:border-red-500 focus:ring-red-500': promoValidation.invalid |
| }" |
| :placeholder="t('auth.promoCodePlaceholder')" |
| @input="handlePromoCodeInput" |
| /> |
| |
| <div v-if="promoValidating" class="absolute inset-y-0 right-0 flex items-center pr-3.5"> |
| <svg class="h-4 w-4 animate-spin text-gray-400" fill="none" viewBox="0 0 24 24"> |
| <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> |
| <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path> |
| </svg> |
| </div> |
| <div v-else-if="promoValidation.valid" class="absolute inset-y-0 right-0 flex items-center pr-3.5"> |
| <Icon name="checkCircle" size="md" class="text-green-500" /> |
| </div> |
| <div v-else-if="promoValidation.invalid" class="absolute inset-y-0 right-0 flex items-center pr-3.5"> |
| <Icon name="exclamationCircle" size="md" class="text-red-500" /> |
| </div> |
| </div> |
| |
| <transition name="fade"> |
| <div v-if="promoValidation.valid" class="mt-2 flex items-center gap-2 rounded-lg bg-green-50 px-3 py-2 dark:bg-green-900/20"> |
| <Icon name="gift" size="sm" class="text-green-600 dark:text-green-400" /> |
| <span class="text-sm text-green-700 dark:text-green-400"> |
| {{ t('auth.promoCodeValid', { amount: promoValidation.bonusAmount?.toFixed(2) }) }} |
| </span> |
| </div> |
| <p v-else-if="promoValidation.invalid" class="input-error-text"> |
| {{ promoValidation.message }} |
| </p> |
| </transition> |
| </div> |
| |
| |
| <div v-if="turnstileEnabled && turnstileSiteKey"> |
| <TurnstileWidget |
| ref="turnstileRef" |
| :site-key="turnstileSiteKey" |
| @verify="onTurnstileVerify" |
| @expire="onTurnstileExpire" |
| @error="onTurnstileError" |
| /> |
| <p v-if="errors.turnstile" class="input-error-text mt-2 text-center"> |
| {{ errors.turnstile }} |
| </p> |
| </div> |
| |
| |
| <transition name="fade"> |
| <div |
| v-if="errorMessage" |
| class="rounded-xl border border-red-200 bg-red-50 p-4 dark:border-red-800/50 dark:bg-red-900/20" |
| > |
| <div class="flex items-start gap-3"> |
| <div class="flex-shrink-0"> |
| <Icon name="exclamationCircle" size="md" class="text-red-500" /> |
| </div> |
| <p class="text-sm text-red-700 dark:text-red-400"> |
| {{ errorMessage }} |
| </p> |
| </div> |
| </div> |
| </transition> |
| |
| |
| <button |
| type="submit" |
| :disabled="isLoading || (turnstileEnabled && !turnstileToken)" |
| class="btn btn-primary w-full" |
| > |
| <svg |
| v-if="isLoading" |
| class="-ml-1 mr-2 h-4 w-4 animate-spin text-white" |
| fill="none" |
| viewBox="0 0 24 24" |
| > |
| <circle |
| class="opacity-25" |
| cx="12" |
| cy="12" |
| r="10" |
| stroke="currentColor" |
| stroke-width="4" |
| ></circle> |
| <path |
| class="opacity-75" |
| fill="currentColor" |
| d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |
| ></path> |
| </svg> |
| <Icon v-else name="userPlus" size="md" class="mr-2" /> |
| {{ |
| isLoading |
| ? t('auth.processing') |
| : emailVerifyEnabled |
| ? t('auth.continue') |
| : t('auth.createAccount') |
| }} |
| </button> |
| </form> |
| </div> |
| |
| |
| <template #footer> |
| <p class="text-gray-500 dark:text-dark-400"> |
| {{ t('auth.alreadyHaveAccount') }} |
| <router-link |
| to="/login" |
| class="font-medium text-primary-600 transition-colors hover:text-primary-500 dark:text-primary-400 dark:hover:text-primary-300" |
| > |
| {{ t('auth.signIn') }} |
| </router-link> |
| </p> |
| </template> |
| </AuthLayout> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, reactive, onMounted, onUnmounted } from 'vue' |
| import { useRouter, useRoute } from 'vue-router' |
| import { useI18n } from 'vue-i18n' |
| import { AuthLayout } from '@/components/layout' |
| import LinuxDoOAuthSection from '@/components/auth/LinuxDoOAuthSection.vue' |
| import Icon from '@/components/icons/Icon.vue' |
| import TurnstileWidget from '@/components/TurnstileWidget.vue' |
| import { useAuthStore, useAppStore } from '@/stores' |
| import { getPublicSettings, validatePromoCode, validateInvitationCode } from '@/api/auth' |
| import { buildAuthErrorMessage } from '@/utils/authError' |
| import { |
| isRegistrationEmailSuffixAllowed, |
| normalizeRegistrationEmailSuffixWhitelist |
| } from '@/utils/registrationEmailPolicy' |
| |
| const { t, locale } = useI18n() |
| |
| |
| |
| const router = useRouter() |
| const route = useRoute() |
| const authStore = useAuthStore() |
| const appStore = useAppStore() |
| |
| |
| |
| const isLoading = ref<boolean>(false) |
| const settingsLoaded = ref<boolean>(false) |
| const errorMessage = ref<string>('') |
| const showPassword = ref<boolean>(false) |
| |
| |
| const registrationEnabled = ref<boolean>(true) |
| const emailVerifyEnabled = ref<boolean>(false) |
| const promoCodeEnabled = ref<boolean>(true) |
| const invitationCodeEnabled = ref<boolean>(false) |
| const turnstileEnabled = ref<boolean>(false) |
| const turnstileSiteKey = ref<string>('') |
| const siteName = ref<string>('Sub2API') |
| const linuxdoOAuthEnabled = ref<boolean>(false) |
| const registrationEmailSuffixWhitelist = ref<string[]>([]) |
| |
| |
| const turnstileRef = ref<InstanceType<typeof TurnstileWidget> | null>(null) |
| const turnstileToken = ref<string>('') |
| |
| |
| const promoValidating = ref<boolean>(false) |
| const promoValidation = reactive({ |
| valid: false, |
| invalid: false, |
| bonusAmount: null as number | null, |
| message: '' |
| }) |
| let promoValidateTimeout: ReturnType<typeof setTimeout> | null = null |
| |
| |
| const invitationValidating = ref<boolean>(false) |
| const invitationValidation = reactive({ |
| valid: false, |
| invalid: false, |
| message: '' |
| }) |
| let invitationValidateTimeout: ReturnType<typeof setTimeout> | null = null |
| |
| const formData = reactive({ |
| email: '', |
| password: '', |
| promo_code: '', |
| invitation_code: '' |
| }) |
| |
| const errors = reactive({ |
| email: '', |
| password: '', |
| turnstile: '', |
| invitation_code: '' |
| }) |
| |
| |
| |
| onMounted(async () => { |
| try { |
| const settings = await getPublicSettings() |
| registrationEnabled.value = settings.registration_enabled |
| emailVerifyEnabled.value = settings.email_verify_enabled |
| promoCodeEnabled.value = settings.promo_code_enabled |
| invitationCodeEnabled.value = settings.invitation_code_enabled |
| turnstileEnabled.value = settings.turnstile_enabled |
| turnstileSiteKey.value = settings.turnstile_site_key || '' |
| siteName.value = settings.site_name || 'Sub2API' |
| linuxdoOAuthEnabled.value = settings.linuxdo_oauth_enabled |
| registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist( |
| settings.registration_email_suffix_whitelist || [] |
| ) |
| |
| |
| if (promoCodeEnabled.value) { |
| const promoParam = route.query.promo as string |
| if (promoParam) { |
| formData.promo_code = promoParam |
| |
| await validatePromoCodeDebounced(promoParam) |
| } |
| } |
| } catch (error) { |
| console.error('Failed to load public settings:', error) |
| } finally { |
| settingsLoaded.value = true |
| } |
| }) |
| |
| onUnmounted(() => { |
| if (promoValidateTimeout) { |
| clearTimeout(promoValidateTimeout) |
| } |
| if (invitationValidateTimeout) { |
| clearTimeout(invitationValidateTimeout) |
| } |
| }) |
| |
| |
| |
| function handlePromoCodeInput(): void { |
| const code = formData.promo_code.trim() |
| |
| |
| promoValidation.valid = false |
| promoValidation.invalid = false |
| promoValidation.bonusAmount = null |
| promoValidation.message = '' |
| |
| if (!code) { |
| promoValidating.value = false |
| return |
| } |
| |
| |
| if (promoValidateTimeout) { |
| clearTimeout(promoValidateTimeout) |
| } |
| |
| promoValidateTimeout = setTimeout(() => { |
| validatePromoCodeDebounced(code) |
| }, 500) |
| } |
| |
| async function validatePromoCodeDebounced(code: string): Promise<void> { |
| if (!code.trim()) return |
| |
| promoValidating.value = true |
| |
| try { |
| const result = await validatePromoCode(code) |
| |
| if (result.valid) { |
| promoValidation.valid = true |
| promoValidation.invalid = false |
| promoValidation.bonusAmount = result.bonus_amount || 0 |
| promoValidation.message = '' |
| } else { |
| promoValidation.valid = false |
| promoValidation.invalid = true |
| promoValidation.bonusAmount = null |
| |
| promoValidation.message = getPromoErrorMessage(result.error_code) |
| } |
| } catch (error) { |
| console.error('Failed to validate promo code:', error) |
| promoValidation.valid = false |
| promoValidation.invalid = true |
| promoValidation.message = t('auth.promoCodeInvalid') |
| } finally { |
| promoValidating.value = false |
| } |
| } |
| |
| function getPromoErrorMessage(errorCode?: string): string { |
| switch (errorCode) { |
| case 'PROMO_CODE_NOT_FOUND': |
| return t('auth.promoCodeNotFound') |
| case 'PROMO_CODE_EXPIRED': |
| return t('auth.promoCodeExpired') |
| case 'PROMO_CODE_DISABLED': |
| return t('auth.promoCodeDisabled') |
| case 'PROMO_CODE_MAX_USED': |
| return t('auth.promoCodeMaxUsed') |
| case 'PROMO_CODE_ALREADY_USED': |
| return t('auth.promoCodeAlreadyUsed') |
| default: |
| return t('auth.promoCodeInvalid') |
| } |
| } |
| |
| |
| |
| function handleInvitationCodeInput(): void { |
| const code = formData.invitation_code.trim() |
| |
| |
| invitationValidation.valid = false |
| invitationValidation.invalid = false |
| invitationValidation.message = '' |
| errors.invitation_code = '' |
| |
| if (!code) { |
| return |
| } |
| |
| |
| if (invitationValidateTimeout) { |
| clearTimeout(invitationValidateTimeout) |
| } |
| |
| invitationValidateTimeout = setTimeout(() => { |
| validateInvitationCodeDebounced(code) |
| }, 500) |
| } |
| |
| async function validateInvitationCodeDebounced(code: string): Promise<void> { |
| invitationValidating.value = true |
| |
| try { |
| const result = await validateInvitationCode(code) |
| |
| if (result.valid) { |
| invitationValidation.valid = true |
| invitationValidation.invalid = false |
| invitationValidation.message = '' |
| } else { |
| invitationValidation.valid = false |
| invitationValidation.invalid = true |
| invitationValidation.message = getInvitationErrorMessage(result.error_code) |
| } |
| } catch { |
| invitationValidation.valid = false |
| invitationValidation.invalid = true |
| invitationValidation.message = t('auth.invitationCodeInvalid') |
| } finally { |
| invitationValidating.value = false |
| } |
| } |
| |
| function getInvitationErrorMessage(errorCode?: string): string { |
| switch (errorCode) { |
| case 'INVITATION_CODE_NOT_FOUND': |
| return t('auth.invitationCodeInvalid') |
| case 'INVITATION_CODE_INVALID': |
| return t('auth.invitationCodeInvalid') |
| case 'INVITATION_CODE_USED': |
| return t('auth.invitationCodeInvalid') |
| case 'INVITATION_CODE_DISABLED': |
| return t('auth.invitationCodeInvalid') |
| default: |
| return t('auth.invitationCodeInvalid') |
| } |
| } |
| |
| |
| |
| function onTurnstileVerify(token: string): void { |
| turnstileToken.value = token |
| errors.turnstile = '' |
| } |
| |
| function onTurnstileExpire(): void { |
| turnstileToken.value = '' |
| errors.turnstile = t('auth.turnstileExpired') |
| } |
| |
| function onTurnstileError(): void { |
| turnstileToken.value = '' |
| errors.turnstile = t('auth.turnstileFailed') |
| } |
| |
| |
| |
| function validateEmail(email: string): boolean { |
| const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
| return emailRegex.test(email) |
| } |
| |
| function buildEmailSuffixNotAllowedMessage(): string { |
| const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist( |
| registrationEmailSuffixWhitelist.value |
| ) |
| if (normalizedWhitelist.length === 0) { |
| return t('auth.emailSuffixNotAllowed') |
| } |
| const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', ' |
| return t('auth.emailSuffixNotAllowedWithAllowed', { |
| suffixes: normalizedWhitelist.join(separator) |
| }) |
| } |
| |
| function validateForm(): boolean { |
| |
| errors.email = '' |
| errors.password = '' |
| errors.turnstile = '' |
| errors.invitation_code = '' |
| |
| let isValid = true |
| |
| |
| if (!formData.email.trim()) { |
| errors.email = t('auth.emailRequired') |
| isValid = false |
| } else if (!validateEmail(formData.email)) { |
| errors.email = t('auth.invalidEmail') |
| isValid = false |
| } else if ( |
| !isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value) |
| ) { |
| errors.email = buildEmailSuffixNotAllowedMessage() |
| isValid = false |
| } |
| |
| |
| if (!formData.password) { |
| errors.password = t('auth.passwordRequired') |
| isValid = false |
| } else if (formData.password.length < 6) { |
| errors.password = t('auth.passwordMinLength') |
| isValid = false |
| } |
| |
| |
| if (invitationCodeEnabled.value) { |
| if (!formData.invitation_code.trim()) { |
| errors.invitation_code = t('auth.invitationCodeRequired') |
| isValid = false |
| } |
| } |
| |
| |
| if (turnstileEnabled.value && !turnstileToken.value) { |
| errors.turnstile = t('auth.completeVerification') |
| isValid = false |
| } |
| |
| return isValid |
| } |
| |
| |
| |
| async function handleRegister(): Promise<void> { |
| |
| errorMessage.value = '' |
| |
| |
| if (!validateForm()) { |
| return |
| } |
| |
| |
| if (formData.promo_code.trim()) { |
| |
| if (promoValidating.value) { |
| errorMessage.value = t('auth.promoCodeValidating') |
| return |
| } |
| |
| if (promoValidation.invalid) { |
| errorMessage.value = t('auth.promoCodeInvalidCannotRegister') |
| return |
| } |
| } |
| |
| |
| if (invitationCodeEnabled.value) { |
| |
| if (invitationValidating.value) { |
| errorMessage.value = t('auth.invitationCodeValidating') |
| return |
| } |
| |
| if (invitationValidation.invalid) { |
| errorMessage.value = t('auth.invitationCodeInvalidCannotRegister') |
| return |
| } |
| |
| if (formData.invitation_code.trim() && !invitationValidation.valid) { |
| errorMessage.value = t('auth.invitationCodeValidating') |
| |
| await validateInvitationCodeDebounced(formData.invitation_code.trim()) |
| if (!invitationValidation.valid) { |
| errorMessage.value = t('auth.invitationCodeInvalidCannotRegister') |
| return |
| } |
| } |
| } |
| |
| isLoading.value = true |
| |
| try { |
| |
| if (emailVerifyEnabled.value) { |
| |
| sessionStorage.setItem( |
| 'register_data', |
| JSON.stringify({ |
| email: formData.email, |
| password: formData.password, |
| turnstile_token: turnstileToken.value, |
| promo_code: formData.promo_code || undefined, |
| invitation_code: formData.invitation_code || undefined |
| }) |
| ) |
| |
| |
| await router.push('/email-verify') |
| return |
| } |
| |
| |
| await authStore.register({ |
| email: formData.email, |
| password: formData.password, |
| turnstile_token: turnstileEnabled.value ? turnstileToken.value : undefined, |
| promo_code: formData.promo_code || undefined, |
| invitation_code: formData.invitation_code || undefined |
| }) |
| |
| |
| appStore.showSuccess(t('auth.accountCreatedSuccess', { siteName: siteName.value })) |
| |
| |
| await router.push('/dashboard') |
| } catch (error: unknown) { |
| |
| if (turnstileRef.value) { |
| turnstileRef.value.reset() |
| turnstileToken.value = '' |
| } |
| |
| |
| errorMessage.value = buildAuthErrorMessage(error, { |
| fallback: t('auth.registrationFailed') |
| }) |
| |
| |
| appStore.showError(errorMessage.value) |
| } finally { |
| isLoading.value = false |
| } |
| } |
| </script> |
| |
| <style scoped> |
| .fade-enter-active, |
| .fade-leave-active { |
| transition: all 0.3s ease; |
| } |
| |
| .fade-enter-from, |
| .fade-leave-to { |
| opacity: 0; |
| transform: translateY(-8px); |
| } |
| </style> |
| |