Files
ai-agent-admin/web/apps/web-ele/src/components/user-avatar/index.vue
T

794 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts" setup>
import type { User } from '#/api/core/user';
import { computed, onMounted, ref, watch } from 'vue';
import { Network } from '@vben/icons';
import { ElAvatar, ElPopover, ElTooltip } from 'element-plus';
import { getUserDetailApi } from '#/api/core/user';
import DeptTag from '#/components/dept-tag/index.vue';
import PostTag from '#/components/post-tag/index.vue';
import RoleTag from '#/components/role-tag/index.vue';
import { getFileUrl } from '#/composables/useFileUrl';
import { $t } from '#/locales';
import { generateAvatarGradient, generateAvatarText } from '#/utils/avatar';
import UserProfileDialog from './UserProfileDialog.vue';
defineOptions({
name: 'UserAvatar',
});
const props = withDefaults(defineProps<Props>(), {
size: 56,
fontSize: 24,
shadow: true,
showPopover: true,
autoLoad: false,
showInfo: false,
hideUsername: false,
infoPosition: 'bottom',
});
interface Props {
/**
* 用户对象(包含 id, name, avatar 等信息)
*/
user?: User;
/**
* 用户ID(用于获取详细信息和用户对象)
*/
userId?: string;
/**
* 用户名字或用户名(可选,如果不提供则从 user 或 userId 中获取)
*/
name?: string;
/**
* 头像文件路径(不需要调用 getFileStreamUrl,组件内部处理)
* @deprecated 建议使用 user 或 userId 代替
*/
avatar?: string;
/**
* 头像尺寸(像素)
* @default 56
*/
size?: number;
/**
* 文字大小(像素)
* @default 24
*/
fontSize?: number;
/**
* 是否显示阴影
* @default true
*/
shadow?: boolean;
/**
* 是否启用悬停显示详细信息
* @default true
*/
showPopover?: boolean;
/**
* 是否自动加载用户信息(用于只传 userId 时自动获取用户名和头像)
* @default false
*/
autoLoad?: boolean;
/**
* 是否在头像下方显示用户名和username
* @default false
*/
showInfo?: boolean;
/**
* 是否隐藏 username(只显示 name
* @default false
*/
hideUsername?: boolean;
/**
* showInfo 的布局方向:bottom 在下方,right 在右侧
* @default 'bottom'
*/
infoPosition?: 'bottom' | 'right';
}
// Popover 相关状态(需要在其他computed/watch之前定义)
const popoverVisible = ref(false);
const userDetail = ref<User>();
const loading = ref(false);
const hasLoaded = ref(false);
// 用户详情 Dialog
const profileDialogVisible = ref(false);
function openProfileDialog() {
popoverVisible.value = false;
profileDialogVisible.value = true;
}
// 获取有效的 ID
const effectiveUserId = computed(() => {
return props.userId || props.user?.id;
});
// 获取有效的用户名
const effectiveUserName = computed(() => {
return props.name || props.user?.name || userDetail.value?.name;
});
// 获取有效的头像 URL(处理 getFileUrl
const effectiveAvatarUrl = ref<string | undefined>(undefined);
// 异步加载头像URL
async function loadAvatarUrl() {
const avatarPath =
props.avatar || props.user?.avatar || userDetail.value?.avatar;
effectiveAvatarUrl.value = avatarPath
? await getFileUrl(avatarPath)
: undefined;
}
// 监听头像路径变化
watch(
() => props.avatar || props.user?.avatar || userDetail.value?.avatar,
() => {
loadAvatarUrl();
},
{ immediate: true },
);
const userInitials = computed(() => {
return generateAvatarText(effectiveUserName.value);
});
const avatarGradient = computed(() => {
return generateAvatarGradient(effectiveUserName.value);
});
// 加载用户详细信息
async function loadUserDetail() {
if (hasLoaded.value) return; // 已加载过,不再重复加载
if (!effectiveUserId.value) {
// 没有 ID 但有 user 对象,直接使用
if (props.user) {
userDetail.value = props.user;
hasLoaded.value = true;
}
return;
}
loading.value = true;
try {
userDetail.value = await getUserDetailApi(effectiveUserId.value);
hasLoaded.value = true;
} catch (error) {
console.error('Failed to load user detail:', error);
// API 失败时回退到 props.user
if (props.user) {
userDetail.value = props.user;
hasLoaded.value = true;
}
} finally {
loading.value = false;
}
}
// Popover 显示时加载数据
function handlePopoverShow() {
if (!hasLoaded.value) {
loadUserDetail();
}
}
// 获取性别显示文本
function getGenderText(gender?: number) {
if (gender === 1) return '男';
if (gender === 0) return '女';
return '未知';
}
// 获取状态标签类型
function getStatusType(isActive?: number) {
return isActive === 1 ? 'success' : 'danger';
}
// 获取状态显示文本
function getStatusText(isActive?: number) {
return isActive === 1 ? '启用' : '禁用';
}
// 自动加载用户信息
onMounted(() => {
// autoLoad 或 showInfo 时都需要加载用户详情
if ((props.autoLoad || props.showInfo) && props.userId && !props.user) {
loadUserDetail();
}
});
// 监听 userId 变化,自动重新加载
watch(
() => props.userId,
(newId, oldId) => {
if ((props.autoLoad || props.showInfo) && newId && newId !== oldId) {
hasLoaded.value = false;
userDetail.value = undefined;
loadUserDetail();
}
},
);
</script>
<template>
<div class="user-avatar-root">
<ElPopover
v-if="showPopover && effectiveUserId"
v-model:visible="popoverVisible"
placement="right"
:width="280"
trigger="hover"
:show-after="300"
@show="handlePopoverShow"
>
<template #reference>
<div
class="avatar-wrapper"
:class="{
'with-info': showInfo,
'info-bottom': showInfo && infoPosition === 'bottom',
'info-right': showInfo && infoPosition === 'right',
}"
>
<div
class="avatar-generator cursor-pointer"
:class="{ 'is-loading': loading }"
>
<div
v-if="!effectiveAvatarUrl"
class="avatar-gradient"
:style="{
width: `${size}px`,
height: `${size}px`,
background: avatarGradient,
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
fontSize: `${fontSize}px`,
}"
>
<span class="avatar-text">{{ userInitials }}</span>
</div>
<ElAvatar
v-else
:src="effectiveAvatarUrl"
:alt="effectiveUserName"
:size="size"
:style="{
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
}"
/>
</div>
<div v-if="showInfo" class="avatar-info">
<div v-if="loading" class="info-skeleton">
<div class="skeleton-line name"></div>
<div class="skeleton-line username"></div>
</div>
<template v-else-if="userDetail">
<div class="info-name">{{ userDetail.name }}</div>
<div v-if="!hideUsername" class="info-username">
@{{ userDetail.username }}
</div>
</template>
</div>
</div>
</template>
<!-- Popover 内容 -->
<div class="user-detail-popover">
<!-- 加载骨架屏 -->
<div v-if="loading || !userDetail" class="popover-skeleton">
<div class="skeleton-header">
<div class="skeleton-avatar"></div>
<div class="skeleton-info">
<div class="skeleton-bar skeleton-name"></div>
<div class="skeleton-bar skeleton-username"></div>
</div>
</div>
<div class="skeleton-details">
<div v-for="i in 6" :key="i" class="skeleton-row">
<div class="skeleton-bar skeleton-label"></div>
<div
class="skeleton-bar skeleton-value"
:style="{ width: `${60 + (i % 3) * 20}px` }"
></div>
</div>
</div>
</div>
<!-- 用户详细信息 -->
<div v-else class="user-detail-content">
<!-- 头像和基本信息 -->
<div class="user-header">
<div class="user-avatar-large">
<div
v-if="!userDetail.avatar"
class="avatar-gradient"
:style="{
width: '64px',
height: '64px',
background: generateAvatarGradient(userDetail.name),
fontSize: '24px',
}"
>
<span class="avatar-text">{{
generateAvatarText(userDetail.name)
}}</span>
</div>
<ElAvatar
v-else
:src="effectiveAvatarUrl"
:alt="userDetail.name"
:size="64"
/>
</div>
<div class="user-basic-info">
<div class="user-name">{{ userDetail.name }}</div>
<div class="user-username">@{{ userDetail.username }}</div>
</div>
<!-- 操作按钮 -->
<div class="user-actions">
<ElTooltip
:content="$t('user-avatar.profile.organization')"
placement="top"
>
<button class="action-btn" @click="openProfileDialog">
<Network :size="15" />
</button>
</ElTooltip>
</div>
</div>
<!-- 详细信息 -->
<div class="user-details">
<div v-if="userDetail.email" class="detail-item">
<span class="detail-label">邮箱</span>
<span class="detail-value">{{ userDetail.email }}</span>
</div>
<div v-if="userDetail.mobile" class="detail-item">
<span class="detail-label">手机</span>
<span class="detail-value">{{ userDetail.mobile }}</span>
</div>
<div v-if="userDetail.city" class="detail-item">
<span class="detail-label">城市</span>
<span class="detail-value">{{ userDetail.city }}</span>
</div>
<div v-if="userDetail.dept_id" class="detail-item">
<span class="detail-label">部门</span>
<span class="detail-value">
<DeptTag :dept-id="userDetail.dept_id" />
</span>
</div>
<div v-if="userDetail.post_id" class="detail-item">
<span class="detail-label">岗位</span>
<span class="detail-value">
<PostTag :post-id="userDetail.post_id" />
</span>
</div>
<div
v-if="userDetail.role_ids && userDetail.role_ids.length > 0"
class="detail-item"
>
<span class="detail-label">角色</span>
<span class="detail-value">
<RoleTag :role-ids="userDetail.role_ids" />
</span>
</div>
<div v-if="userDetail.manager_id" class="detail-item">
<span class="detail-label">经理</span>
<span class="detail-value">
<UserAvatar
:user-id="userDetail.manager_id"
:size="20"
:font-size="10"
:shadow="false"
show-info
hide-username
info-position="right"
/>
</span>
</div>
<!-- <div class="detail-item">
<span class="detail-label">性别</span>
<span class="detail-value">{{ getGenderText(userDetail.gender) }}</span>
</div> -->
<div v-if="userDetail.user_type_display" class="detail-item">
<span class="detail-label">类型</span>
<span class="detail-value">{{
userDetail.user_type_display
}}</span>
</div>
<!-- <div class="detail-item">
<span class="detail-label">状态</span>
<ElTag :type="getStatusType(userDetail.is_active)" size="small">
{{ getStatusText(userDetail.is_active) }}
</ElTag>
</div> -->
</div>
</div>
</div>
</ElPopover>
<!-- 用户详情 Dialog -->
<UserProfileDialog
v-if="effectiveUserId"
v-model="profileDialogVisible"
:user-id="effectiveUserId"
/>
<!-- Popover -->
<div
v-if="!(showPopover && effectiveUserId)"
class="avatar-wrapper"
:class="{
'with-info': showInfo,
'info-bottom': showInfo && infoPosition === 'bottom',
'info-right': showInfo && infoPosition === 'right',
}"
>
<div class="avatar-generator" :class="{ 'is-loading': loading }">
<div
v-if="!effectiveAvatarUrl"
class="avatar-gradient"
:style="{
width: `${size}px`,
height: `${size}px`,
background: avatarGradient,
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
fontSize: `${fontSize}px`,
}"
>
<span class="avatar-text">{{ userInitials }}</span>
</div>
<ElAvatar
v-else
:src="effectiveAvatarUrl"
:alt="effectiveUserName"
:size="size"
:style="{
boxShadow: shadow ? '0 2px 8px rgba(0, 0, 0, 0.15)' : 'none',
}"
/>
</div>
<div v-if="showInfo" class="avatar-info">
<div v-if="loading" class="info-skeleton">
<div class="skeleton-line name"></div>
<div class="skeleton-line username"></div>
</div>
<template v-else-if="userDetail">
<div class="info-name">{{ userDetail.name }}</div>
<div v-if="!hideUsername" class="info-username">
@{{ userDetail.username }}
</div>
</template>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.user-avatar-root {
display: inline-flex;
}
.avatar-wrapper {
display: inline-flex;
align-items: center;
&.info-bottom {
flex-direction: column;
gap: 6px;
}
&.info-right {
flex-direction: row;
gap: 8px;
}
}
.avatar-generator {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
&.is-loading {
.avatar-gradient,
:deep(.el-avatar) {
animation: pulse 1.5s ease-in-out infinite;
}
}
.avatar-gradient {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 50%;
.avatar-text {
font-weight: 700;
color: white;
white-space: nowrap;
text-shadow: 0 1px 2px rgb(0 0 0 / 20%);
}
}
:deep(.el-avatar) {
flex-shrink: 0;
}
}
.avatar-info {
min-width: 60px;
max-width: 80px;
text-align: center;
.info-right & {
max-width: 120px;
text-align: left;
}
.info-name {
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px;
font-weight: 500;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.info-username {
overflow: hidden;
text-overflow: ellipsis;
font-size: 11px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
.info-skeleton {
display: flex;
flex-direction: column;
gap: 4px;
align-items: center;
.info-right & {
align-items: flex-start;
}
.skeleton-line {
background: linear-gradient(
90deg,
var(--el-fill-color) 25%,
var(--el-fill-color-light) 50%,
var(--el-fill-color) 75%
);
background-size: 200% 100%;
border-radius: 4px;
animation: shimmer 1.5s infinite;
&.name {
width: 48px;
height: 12px;
}
&.username {
width: 56px;
height: 10px;
}
}
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.user-detail-popover {
padding: 12px;
.popover-skeleton {
.skeleton-header {
display: flex;
gap: 12px;
align-items: center;
padding-bottom: 12px;
}
.skeleton-avatar {
flex-shrink: 0;
width: 64px;
height: 64px;
border-radius: 50%;
background: linear-gradient(
90deg,
var(--el-fill-color-light) 25%,
var(--el-fill-color) 50%,
var(--el-fill-color-light) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-info {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
}
.skeleton-bar {
border-radius: 4px;
background: linear-gradient(
90deg,
var(--el-fill-color-light) 25%,
var(--el-fill-color) 50%,
var(--el-fill-color-light) 75%
);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}
.skeleton-name {
width: 72px;
height: 16px;
}
.skeleton-username {
width: 56px;
height: 16px;
}
.skeleton-details {
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 12px;
}
.skeleton-row {
display: flex;
gap: 16px;
align-items: center;
}
.skeleton-label {
flex-shrink: 0;
width: 42px;
height: 16px;
}
.skeleton-value {
height: 16px;
}
}
.user-detail-content {
.user-header {
display: flex;
gap: 12px;
align-items: center;
padding-bottom: 12px;
// border-bottom: 1px solid hsl(var(--border));
.user-avatar-large {
flex-shrink: 0;
.avatar-gradient {
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
box-shadow: 0 2px 8px rgb(0 0 0 / 15%);
.avatar-text {
font-weight: 700;
color: white;
text-shadow: 0 1px 2px rgb(0 0 0 / 20%);
}
}
}
.user-basic-info {
flex: 1;
min-width: 0;
.user-name {
margin-bottom: 4px;
overflow: hidden;
text-overflow: ellipsis;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
white-space: nowrap;
}
.user-username {
overflow: hidden;
text-overflow: ellipsis;
font-size: 13px;
color: var(--el-text-color-secondary);
white-space: nowrap;
}
}
}
.user-actions {
display: flex;
gap: 6px;
padding: 8px 0;
.action-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--el-text-color-secondary);
// background: var(--el-fill-color-light);
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
&:hover {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
}
.user-details {
padding-top: 12px;
.detail-item {
display: flex;
align-items: center;
padding: 6px 0;
font-size: 13px;
.detail-label {
flex-shrink: 0;
width: 60px;
color: var(--el-text-color-secondary);
}
.detail-value {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
color: var(--el-text-color-primary);
white-space: nowrap;
}
}
}
}
}
</style>