refactor(ui): share user avatar fallbacks (#2143)

This commit is contained in:
RainbowBird
2026-07-29 18:38:37 +08:00
committed by GitHub
parent c1ca39f1e6
commit 97cf26082f
8 changed files with 182 additions and 63 deletions
@@ -1,29 +1,22 @@
<script setup lang="ts">
import { signOut } from '@proj-airi/stage-ui/libs/auth'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Avatar } from '@proj-airi/ui'
import { onClickOutside } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterLink } from 'vue-router'
const authStore = useAuthStore()
const { isAuthenticated, user, credits } = storeToRefs(authStore)
const { t } = useI18n()
const userName = computed(() => user.value?.name)
const userAvatar = computed(() => user.value?.image)
const showDropdown = ref(false)
const dropdownRef = ref(null)
// Fall back to the user-icon placeholder when the avatar URL fails to load
// (broken/expired host, network error, hot-linked image taken down). Without
// this the header pill renders the browser's default broken-image glyph,
// which looks worse than the explicit placeholder we already ship.
// Reset on URL change so a fixed URL re-attempts loading.
const avatarLoadError = ref(false)
watch(userAvatar, () => {
avatarLoadError.value = false
})
const formattedCredits = computed(() => credits.value.toLocaleString())
onClickOutside(dropdownRef, () => {
@@ -65,22 +58,17 @@ onClickOutside(dropdownRef, () => {
class="flex items-center gap-2 border-2 border-neutral-100/60 rounded-full bg-neutral-50/70 p-1 pl-1 pr-3 backdrop-blur-md transition dark:border-neutral-800/30 dark:bg-neutral-800/70 hover:bg-neutral-100 dark:hover:bg-neutral-800"
:class="{ 'ring-2 ring-primary-500/20': showDropdown }"
aria-haspopup="true"
:aria-label="userName || t('settings.pages.account.title')"
:aria-expanded="showDropdown ? 'true' : 'false'"
@click="showDropdown = !showDropdown"
>
<img
v-if="userAvatar && !avatarLoadError"
<Avatar
:src="userAvatar"
:alt="userName"
class="h-7 w-7 rounded-full object-cover"
@error="avatarLoadError = true"
>
<div
v-else
class="h-7 w-7 flex items-center justify-center rounded-full bg-neutral-200 text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400"
>
<div class="i-solar:user-bold-duotone text-lg" />
</div>
:class="[
'h-7 w-7 rounded-full',
'bg-neutral-200 text-neutral-500 dark:bg-neutral-700 dark:text-neutral-400',
]"
/>
<span v-if="userName" class="max-w-[100px] truncate text-sm text-neutral-700 font-medium hidden sm:block dark:text-neutral-200">
{{ userName }}
@@ -5,7 +5,7 @@ import { resolveLinkedAccountOAuthErrorMessageKey, useAnalytics, useLinkedAccoun
import { authClient } from '@proj-airi/stage-ui/libs/auth'
import { SERVER_URL } from '@proj-airi/stage-ui/libs/server'
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
import { Button, FieldInput } from '@proj-airi/ui'
import { Avatar, Button, FieldInput } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import { computed, reactive, ref, shallowRef, watch } from 'vue'
@@ -50,14 +50,6 @@ const gravatarProfileUrl = computed(() => {
return `https://gravatar.com/${encodeURIComponent(userEmail.value.trim().toLowerCase())}`
})
// Track avatar load failure so we can fall back to the placeholder icon
// instead of rendering an alt-text overflow inside the circle. Resets when
// the URL changes so a fixed URL re-attempts loading.
const avatarLoadError = ref(false)
watch(userAvatar, () => {
avatarLoadError.value = false
})
// Locale-aware thousand separator. Bare 56 digit numbers are noisy to scan
// (e.g. "44965" reads as one block); Intl.NumberFormat respects user locale
// (44,965 / 44 965 / 44.965 depending on region) without us having to ship a
@@ -473,16 +465,10 @@ async function handleConfirmDelete(event: Event) {
about the same account, not a separate concern. -->
<section :class="['flex flex-col gap-3 pb-6 border-b border-neutral-200/70 dark:border-neutral-800/60']">
<div :class="['flex items-center gap-4 py-2']">
<div :class="['size-16 sm:size-20 rounded-full overflow-hidden flex-shrink-0', 'bg-neutral-100 dark:bg-neutral-800', 'flex items-center justify-center']">
<img
v-if="userAvatar && !avatarLoadError"
:src="userAvatar"
:alt="userName"
:class="['size-full object-cover']"
@error="avatarLoadError = true"
>
<div v-else :class="['i-solar:user-circle-bold-duotone', 'size-10 text-neutral-400']" />
</div>
<Avatar
:src="userAvatar"
:class="['size-16 sm:size-20 rounded-full flex-shrink-0', 'bg-neutral-100 dark:bg-neutral-800', 'flex items-center justify-center']"
/>
<div :class="['flex flex-col gap-0.5 min-w-0']">
<span :class="['text-xs text-neutral-500 dark:text-neutral-400']">
{{ t('settings.pages.account.signedInAs') }}
@@ -0,0 +1,58 @@
<script setup lang="ts">
import type { ImgHTMLAttributes } from 'vue'
import { AvatarFallback, AvatarImage, AvatarRoot } from 'reka-ui'
interface AvatarProps {
/**
* Image URL. Missing or failed images render the fallback.
*
* @default null
*/
src?: string | null
/**
* Accessible description for both the image and fallback. Omit it when the
* surrounding content already identifies the user and the avatar is decorative.
*
* @default null
*/
alt?: string | null
/** Referrer policy forwarded to the underlying image request. */
referrerPolicy?: ImgHTMLAttributes['referrerpolicy']
/** Cross-origin mode forwarded to the underlying image request. */
crossOrigin?: ImgHTMLAttributes['crossorigin']
}
const props = withDefaults(defineProps<AvatarProps>(), {
src: null,
alt: null,
})
</script>
<template>
<AvatarRoot
:key="props.src ?? ''"
:class="['overflow-hidden']"
>
<AvatarImage
v-if="props.src"
data-avatar-image
:src="props.src"
:alt="props.alt ?? ''"
:referrer-policy="props.referrerPolicy"
:cross-origin="props.crossOrigin"
:class="['size-full object-cover']"
/>
<AvatarFallback
data-avatar-fallback
role="img"
:aria-label="props.alt || undefined"
:aria-hidden="props.alt ? undefined : 'true'"
:class="['size-full flex items-center justify-center']"
>
<slot name="fallback">
<div :class="['i-solar:user-circle-bold-duotone', 'size-1/2 text-neutral-400']" />
</slot>
</AvatarFallback>
</AvatarRoot>
</template>
+1
View File
@@ -1,3 +1,4 @@
export { default as Avatar } from './avatar.vue'
export { default as Button } from './button.vue'
export { default as Callout } from './callout.vue'
export { default as ContainerError } from './container-error.vue'