mirror of https://github.com/halo-dev/halo-admin
perf: refine the fallback of the avatar component (#649)
#### What type of PR is this? /kind improvement /milestone 2.0 #### What this PR does / why we need it: 优化 Avatar 组件在图片加载失败的状态,以及添加加载状态的动画。目前的策略为,如果图片加载异常,那么会取 `alt` 属性生成占位样式。如果没有设置 `alt`,会显示失败的图标。 #### Screenshots: <img width="421" alt="image" src="https://user-images.githubusercontent.com/21301288/196194978-229cf58d-19e1-4492-b77a-8b1a6b41e3a0.png"> #### Special notes for your reviewer: /cc @halo-dev/sig-halo-console 测试方式: 1. 需要 `pnpm build:packages` 2. 测试修改个人资料的头像,观测不同效果。 3. 访问 https://halo-admin-ui-git-fork-ruibaby-perf-avatar-fallback-halo-dev.vercel.app/story/src-components-avatar-avatar-story-vue?variantId=_default 测试不同参数的效果。 #### Does this PR introduce a user-facing change? ```release-note 优化 Avatar 组件在图片加载失败的状态 ```pull/645/head^2
parent
2ae2cfad00
commit
6d8a2ddd75
|
@ -1,8 +1,49 @@
|
|||
<script lang="ts" setup>
|
||||
import { VAvatar } from "./index";
|
||||
import { VSpace } from "../space";
|
||||
</script>
|
||||
<template>
|
||||
<Story title="Avatar">
|
||||
<VAvatar src="https://ryanc.cc/avatar" size="lg" />
|
||||
<VSpace direction="column">
|
||||
<VSpace>
|
||||
<VAvatar size="md" alt="Halo" src="https://halo.run/logo" />
|
||||
<VAvatar size="md" alt="Ryan Wang" src="https://ryanc.cc/avatar" />
|
||||
<VAvatar size="md" alt="John Niang" src="https://johnniang.me/avatar" />
|
||||
<VAvatar size="md" alt="guqing" src="https://guqing.xyz/avatar" />
|
||||
</VSpace>
|
||||
<VSpace>
|
||||
<VAvatar size="md" alt="Halo" />
|
||||
<VAvatar size="md" alt="Ryan Wang" />
|
||||
<VAvatar size="md" alt="John Niang" />
|
||||
<VAvatar size="md" alt="guqing" />
|
||||
</VSpace>
|
||||
<VSpace>
|
||||
<VAvatar size="md" circle alt="Halo" />
|
||||
<VAvatar size="md" circle alt="Ryan Wang" />
|
||||
<VAvatar size="md" circle alt="John Niang" />
|
||||
<VAvatar size="md" circle alt="guqing" />
|
||||
</VSpace>
|
||||
<VSpace>
|
||||
<VAvatar size="xs" circle alt="Halo" src="https://halo.run/logo" />
|
||||
<VAvatar
|
||||
size="sm"
|
||||
circle
|
||||
alt="Ryan Wang"
|
||||
src="https://ryanc.cc/avatar"
|
||||
/>
|
||||
<VAvatar
|
||||
size="md"
|
||||
circle
|
||||
alt="John Niang"
|
||||
src="https://johnniang.me/avatar"
|
||||
/>
|
||||
<VAvatar
|
||||
size="lg"
|
||||
circle
|
||||
alt="guqing"
|
||||
src="https://guqing.xyz/avatar"
|
||||
/>
|
||||
</VSpace>
|
||||
</VSpace>
|
||||
</Story>
|
||||
</template>
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from "vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { IconErrorWarning } from "../../icons/icons";
|
||||
import type { Size } from "./interface";
|
||||
import { useImage } from "@vueuse/core";
|
||||
import { IconUserLine } from "@/icons/icons";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
@ -16,13 +15,41 @@ const props = withDefaults(
|
|||
{
|
||||
src: undefined,
|
||||
alt: undefined,
|
||||
size: undefined,
|
||||
size: "md",
|
||||
width: undefined,
|
||||
height: undefined,
|
||||
circle: false,
|
||||
}
|
||||
);
|
||||
|
||||
const isLoading = ref(false);
|
||||
const error = ref(false);
|
||||
|
||||
const loadImage = async () => {
|
||||
const image = new Image();
|
||||
image.src = props.src;
|
||||
return new Promise((resolve, reject) => {
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = (err) => reject(err);
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
if (!props.src) {
|
||||
error.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
try {
|
||||
await loadImage();
|
||||
} catch (e) {
|
||||
error.value = true;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const classes = computed(() => {
|
||||
const result = [`avatar-${props.circle ? "circle" : "square"}`];
|
||||
if (props.size) {
|
||||
|
@ -42,13 +69,49 @@ const styles = computed(() => {
|
|||
return result;
|
||||
});
|
||||
|
||||
const { isLoading, error } = useImage({ src: props.src });
|
||||
const placeholderText = computed(() => {
|
||||
if (!props.alt) {
|
||||
return undefined;
|
||||
}
|
||||
const words = props.alt.split(" ");
|
||||
if (words.length === 1) {
|
||||
return words[0].charAt(0).toUpperCase();
|
||||
}
|
||||
if (words.length > 1) {
|
||||
return words[0].charAt(0).toUpperCase() + words[1].charAt(0).toUpperCase();
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="avatar-wrapper" :class="classes" :style="styles">
|
||||
<div v-if="isLoading || error" class="w-full h-full">
|
||||
<IconUserLine class="w-full h-full" />
|
||||
<div v-if="isLoading || error" class="avatar-fallback">
|
||||
<svg
|
||||
v-if="isLoading"
|
||||
class="avatar-loading"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle
|
||||
class="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-width="4"
|
||||
></circle>
|
||||
<path
|
||||
class="opacity-75"
|
||||
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"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<span v-else-if="placeholderText" class="avatar-placeholder">
|
||||
{{ placeholderText }}
|
||||
</span>
|
||||
<IconErrorWarning v-else class="avatar-error" />
|
||||
</div>
|
||||
<img v-else :src="src" :alt="alt" />
|
||||
</div>
|
||||
|
@ -56,12 +119,28 @@ const { isLoading, error } = useImage({ src: props.src });
|
|||
|
||||
<style lang="scss">
|
||||
.avatar-wrapper {
|
||||
@apply inline-flex items-center justify-center overflow-hidden bg-white;
|
||||
@apply inline-flex items-center justify-center overflow-hidden bg-gray-100;
|
||||
|
||||
img {
|
||||
@apply w-full h-full object-cover;
|
||||
}
|
||||
|
||||
.avatar-fallback {
|
||||
@apply w-full h-full flex items-center justify-center;
|
||||
}
|
||||
|
||||
.avatar-loading {
|
||||
@apply animate-spin w-5 h-5;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
@apply text-sm text-gray-800 font-medium;
|
||||
}
|
||||
|
||||
.avatar-error {
|
||||
@apply w-5 h-5 text-red-500;
|
||||
}
|
||||
|
||||
&.avatar-circle {
|
||||
@apply rounded-full;
|
||||
}
|
||||
|
@ -72,10 +151,18 @@ const { isLoading, error } = useImage({ src: props.src });
|
|||
|
||||
&.avatar-xs {
|
||||
@apply w-6 h-6;
|
||||
|
||||
.avatar-placeholder {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
&.avatar-sm {
|
||||
@apply w-8 h-8;
|
||||
|
||||
.avatar-placeholder {
|
||||
@apply text-xs;
|
||||
}
|
||||
}
|
||||
|
||||
&.avatar-md {
|
||||
|
|
|
@ -259,7 +259,12 @@ const subjectRefResult = computed(() => {
|
|||
<template #start>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<VAvatar circle :src="comment?.owner.avatar" size="md"></VAvatar>
|
||||
<VAvatar
|
||||
circle
|
||||
:src="comment?.owner.avatar"
|
||||
:alt="comment?.owner.displayName"
|
||||
size="md"
|
||||
></VAvatar>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField
|
||||
|
|
|
@ -104,7 +104,12 @@ const isHoveredReply = computed(() => {
|
|||
<template #start>
|
||||
<VEntityField>
|
||||
<template #description>
|
||||
<VAvatar circle :src="reply?.owner.avatar" size="md"></VAvatar>
|
||||
<VAvatar
|
||||
circle
|
||||
:src="reply?.owner.avatar"
|
||||
:alt="reply?.owner.displayName"
|
||||
size="md"
|
||||
></VAvatar>
|
||||
</template>
|
||||
</VEntityField>
|
||||
<VEntityField
|
||||
|
|
|
@ -93,6 +93,7 @@ const handleTabChange = (id: string) => {
|
|||
<div class="h-24 w-24 sm:h-32 sm:w-32">
|
||||
<VAvatar
|
||||
:src="user?.spec?.avatar"
|
||||
:alt="user?.spec?.displayName"
|
||||
circle
|
||||
width="100%"
|
||||
height="100%"
|
||||
|
|
Loading…
Reference in New Issue