Compare commits

...

6 Commits

Author SHA1 Message Date
Tim
4b8229b0a1 feat: refresh base user avatar styling 2025-09-24 01:21:12 +08:00
tim
6e4fbc3c42 fix: base avatar 重构 2025-09-24 00:43:57 +08:00
Tim
779264623c Merge pull request #1018 from nagisa77/codex/create-baseuseravatar-component-zv8hyo
feat: add base user avatar component
2025-09-24 00:31:11 +08:00
tim
a1eccb3b1e Revert "feat: add BaseUserAvatar and unify avatar usage"
This reverts commit efbb83924b.
2025-09-24 00:30:23 +08:00
Tim
0f75a95dbe Merge pull request #1017 from nagisa77/codex/create-baseuseravatar-component
feat: unify avatar rendering with BaseUserAvatar
2025-09-24 00:27:10 +08:00
Tim
efbb83924b feat: add BaseUserAvatar and unify avatar usage 2025-09-24 00:26:51 +08:00
3 changed files with 110 additions and 37 deletions

View File

@@ -1,28 +1,24 @@
<template> <template>
<NuxtLink <component
v-if="isLink" :is="wrapperTag"
:to="resolvedLink" :to="isLink ? resolvedLink : undefined"
class="base-user-avatar" class="base-user-avatar"
:class="wrapperClass" :class="wrapperClass"
:style="wrapperStyle" :style="wrapperStyle"
v-bind="wrapperAttrs" v-bind="wrapperAttrs"
:role="isLink ? undefined : 'img'"
:aria-label="altText"
:title="altText"
> >
<img :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" /> <span class="base-user-avatar-backdrop" aria-hidden="true" />
</NuxtLink> <BaseImage :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
<div </component>
v-else
class="base-user-avatar"
:class="wrapperClass"
:style="wrapperStyle"
v-bind="wrapperAttrs"
>
<img :src="currentSrc" :alt="altText" class="base-user-avatar-img" @error="onError" />
</div>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useAttrs } from 'vue' import { useAttrs } from 'vue'
import BaseImage from './BaseImage.vue'
const DEFAULT_AVATAR = '/default-avatar.svg' const DEFAULT_AVATAR = '/default-avatar.svg'
@@ -76,8 +72,6 @@ const resolvedLink = computed(() => {
return null return null
}) })
const isLink = computed(() => !props.disableLink && Boolean(resolvedLink.value))
const altText = computed(() => props.alt || '用户头像') const altText = computed(() => props.alt || '用户头像')
const sizeStyle = computed(() => { const sizeStyle = computed(() => {
@@ -87,15 +81,45 @@ const sizeStyle = computed(() => {
return { width: value, height: value } return { width: value, height: value }
}) })
const wrapperStyle = computed(() => { const accentHue = computed(() => {
const attrStyle = attrs.style const seed = props.userId ?? props.alt
return [sizeStyle.value, attrStyle] const source = seed !== undefined && seed !== null ? String(seed) : ''
if (!source) return 198
let hash = 0
for (let index = 0; index < source.length; index += 1) {
hash = (hash << 5) - hash + source.charCodeAt(index)
hash |= 0
}
return Math.abs(hash) % 360
}) })
const wrapperClass = computed(() => [attrs.class, { 'is-rounded': props.rounded }]) const accentStyles = computed(() => {
const hue = accentHue.value
return {
'--avatar-accent': `hsl(${hue}, 74%, 54%)`,
'--avatar-accent-light': `hsl(${hue}, 95%, 82%)`,
'--avatar-accent-soft': `hsl(${hue}, 96%, 95%)`,
'--avatar-accent-border': `hsla(${hue}, 70%, 48%, 0.28)`,
'--avatar-accent-shadow': `hsla(${hue}, 68%, 36%, 0.2)`,
}
})
const wrapperStyle = computed(() => {
const attrStyle = attrs.style
return [accentStyles.value, sizeStyle.value, attrStyle]
})
const isLink = computed(() => !props.disableLink && !!resolvedLink.value)
const wrapperTag = computed(() => (isLink.value ? 'NuxtLink' : 'div'))
const wrapperClass = computed(() => [
attrs.class,
{ 'is-rounded': props.rounded, 'is-interactive': isLink.value },
])
const wrapperAttrs = computed(() => { const wrapperAttrs = computed(() => {
const { class: _class, style: _style, ...rest } = attrs const { class: _class, style: _style, to: _to, href: _href, ...rest } = attrs
return rest return rest
}) })
@@ -108,11 +132,26 @@ function onError() {
<style scoped> <style scoped>
.base-user-avatar { .base-user-avatar {
position: relative;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
overflow: hidden; overflow: hidden;
background-color: var(--avatar-placeholder-color, #f0f0f0); border-radius: 16px;
background: linear-gradient(
140deg,
var(--avatar-accent-soft, rgba(17, 182, 197, 0.12)) 0%,
var(--avatar-accent-light, rgba(17, 182, 197, 0.22)) 100%
);
border: 1px solid var(--avatar-accent-border, rgba(17, 182, 197, 0.2));
box-shadow:
0 1px 2px rgba(15, 52, 67, 0.08),
0 3px 8px var(--avatar-accent-shadow, rgba(17, 182, 197, 0.18));
transition:
transform 0.3s ease,
box-shadow 0.3s ease,
border-color 0.3s ease,
background 0.3s ease;
} }
.base-user-avatar.is-rounded { .base-user-avatar.is-rounded {
@@ -123,10 +162,54 @@ function onError() {
border-radius: 0; border-radius: 0;
} }
.base-user-avatar-backdrop {
position: absolute;
inset: 0;
pointer-events: none;
background:
radial-gradient(circle at 28% 28%, rgba(255, 255, 255, 0.72), transparent 62%),
linear-gradient(150deg, rgba(255, 255, 255, 0.08), transparent),
linear-gradient(
140deg,
var(--avatar-accent-soft, rgba(17, 182, 197, 0.08)) 0%,
var(--avatar-accent-light, rgba(17, 182, 197, 0.18)) 100%
);
opacity: 0.75;
transition:
opacity 0.35s ease,
transform 0.35s ease;
z-index: 0;
}
.base-user-avatar-img { .base-user-avatar-img {
position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
z-index: 1;
border-radius: inherit;
transition: transform 0.35s ease;
}
.base-user-avatar.is-interactive:hover,
.base-user-avatar.is-interactive:focus-visible {
transform: translateY(-1px) scale(1.02);
border-color: var(--avatar-accent, var(--primary-color, #0a6e78));
box-shadow:
0 6px 16px var(--avatar-accent-shadow, rgba(17, 182, 197, 0.24)),
0 3px 6px rgba(15, 52, 67, 0.18);
outline: none;
}
.base-user-avatar.is-interactive:hover .base-user-avatar-backdrop,
.base-user-avatar.is-interactive:focus-visible .base-user-avatar-backdrop {
opacity: 1;
transform: scale(1.05);
}
.base-user-avatar.is-interactive:hover .base-user-avatar-img,
.base-user-avatar.is-interactive:focus-visible .base-user-avatar-img {
transform: scale(1.02);
} }
</style> </style>

View File

@@ -1,7 +1,7 @@
<template> <template>
<div class="user-list"> <div class="user-list">
<BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="inbox" /> <BasePlaceholder v-if="users.length === 0" text="暂无用户" icon="inbox" />
<div v-for="u in users" :key="u.id" class="user-item" @click="handleUserClick(u)"> <div v-for="u in users" :key="u.id" class="user-item">
<BaseUserAvatar :src="u.avatar" :user-id="u.id" alt="avatar" class="user-avatar" /> <BaseUserAvatar :src="u.avatar" :user-id="u.id" alt="avatar" class="user-avatar" />
<div class="user-info"> <div class="user-info">
<div class="user-name">{{ u.username }}</div> <div class="user-name">{{ u.username }}</div>

View File

@@ -85,20 +85,16 @@
</div> </div>
<div class="article-member-avatars-container"> <div class="article-member-avatars-container">
<NuxtLink <div v-for="member in article.members">
v-for="member in article.members"
:key="`${article.id}-${member.id}`"
class="article-member-avatar-item"
:to="`/users/${member.id}`"
>
<BaseUserAvatar <BaseUserAvatar
class="article-member-avatar-item-img" class="article-member-avatar-item-img"
:src="member.avatar" :src="member.avatar"
:user-id="member.id" :user-id="member.id"
alt="avatar" alt="avatar"
:disable-link="true" :disable-link="true"
:width="25"
/> />
</NuxtLink> </div>
</div> </div>
<div class="article-comments main-info-text"> <div class="article-comments main-info-text">
@@ -634,13 +630,6 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
margin-left: 20px; margin-left: 20px;
} }
.article-member-avatar-item {
width: 25px;
height: 25px;
border-radius: 50%;
overflow: hidden;
}
.article-member-avatar-item-img { .article-member-avatar-item-img {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -703,6 +692,7 @@ watch([selectedCategory, selectedTags], ([newCategory, newTags]) => {
margin-left: 0px; margin-left: 0px;
gap: 0px; gap: 0px;
} }
.article-main-container, .article-main-container,
.header-item.main-item { .header-item.main-item {
width: calc(70% - 20px); width: calc(70% - 20px);