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>
|
<script lang="ts" setup>
|
||||||
import { VAvatar } from "./index";
|
import { VAvatar } from "./index";
|
||||||
|
import { VSpace } from "../space";
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<Story title="Avatar">
|
<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>
|
</Story>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
<script lang="ts" setup>
|
<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 type { Size } from "./interface";
|
||||||
import { useImage } from "@vueuse/core";
|
|
||||||
import { IconUserLine } from "@/icons/icons";
|
|
||||||
|
|
||||||
const props = withDefaults(
|
const props = withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
|
@ -16,13 +15,41 @@ const props = withDefaults(
|
||||||
{
|
{
|
||||||
src: undefined,
|
src: undefined,
|
||||||
alt: undefined,
|
alt: undefined,
|
||||||
size: undefined,
|
size: "md",
|
||||||
width: undefined,
|
width: undefined,
|
||||||
height: undefined,
|
height: undefined,
|
||||||
circle: false,
|
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 classes = computed(() => {
|
||||||
const result = [`avatar-${props.circle ? "circle" : "square"}`];
|
const result = [`avatar-${props.circle ? "circle" : "square"}`];
|
||||||
if (props.size) {
|
if (props.size) {
|
||||||
|
@ -42,13 +69,49 @@ const styles = computed(() => {
|
||||||
return result;
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="avatar-wrapper" :class="classes" :style="styles">
|
<div class="avatar-wrapper" :class="classes" :style="styles">
|
||||||
<div v-if="isLoading || error" class="w-full h-full">
|
<div v-if="isLoading || error" class="avatar-fallback">
|
||||||
<IconUserLine class="w-full h-full" />
|
<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>
|
</div>
|
||||||
<img v-else :src="src" :alt="alt" />
|
<img v-else :src="src" :alt="alt" />
|
||||||
</div>
|
</div>
|
||||||
|
@ -56,12 +119,28 @@ const { isLoading, error } = useImage({ src: props.src });
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
.avatar-wrapper {
|
.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 {
|
img {
|
||||||
@apply w-full h-full object-cover;
|
@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 {
|
&.avatar-circle {
|
||||||
@apply rounded-full;
|
@apply rounded-full;
|
||||||
}
|
}
|
||||||
|
@ -72,10 +151,18 @@ const { isLoading, error } = useImage({ src: props.src });
|
||||||
|
|
||||||
&.avatar-xs {
|
&.avatar-xs {
|
||||||
@apply w-6 h-6;
|
@apply w-6 h-6;
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.avatar-sm {
|
&.avatar-sm {
|
||||||
@apply w-8 h-8;
|
@apply w-8 h-8;
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
@apply text-xs;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.avatar-md {
|
&.avatar-md {
|
||||||
|
|
|
@ -259,7 +259,12 @@ const subjectRefResult = computed(() => {
|
||||||
<template #start>
|
<template #start>
|
||||||
<VEntityField>
|
<VEntityField>
|
||||||
<template #description>
|
<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>
|
</template>
|
||||||
</VEntityField>
|
</VEntityField>
|
||||||
<VEntityField
|
<VEntityField
|
||||||
|
|
|
@ -104,7 +104,12 @@ const isHoveredReply = computed(() => {
|
||||||
<template #start>
|
<template #start>
|
||||||
<VEntityField>
|
<VEntityField>
|
||||||
<template #description>
|
<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>
|
</template>
|
||||||
</VEntityField>
|
</VEntityField>
|
||||||
<VEntityField
|
<VEntityField
|
||||||
|
|
|
@ -93,6 +93,7 @@ const handleTabChange = (id: string) => {
|
||||||
<div class="h-24 w-24 sm:h-32 sm:w-32">
|
<div class="h-24 w-24 sm:h-32 sm:w-32">
|
||||||
<VAvatar
|
<VAvatar
|
||||||
:src="user?.spec?.avatar"
|
:src="user?.spec?.avatar"
|
||||||
|
:alt="user?.spec?.displayName"
|
||||||
circle
|
circle
|
||||||
width="100%"
|
width="100%"
|
||||||
height="100%"
|
height="100%"
|
||||||
|
|
Loading…
Reference in New Issue