Profile picture display has been standardized across the board.

This commit is contained in:
2026-10-06 14:23:59 +03:30
parent bc01384551
commit 43717392b2
9 changed files with 154 additions and 21 deletions
+27 -4
View File
@@ -1,29 +1,52 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { personInitials } from '@/lib/person-insights';
import { DEFAULT_USER_AVATAR, primeUserAvatar, userAvatarSrc } from '@/lib/userAvatars';
const props = withDefaults(
defineProps<{
name?: string | null;
src?: string | null;
/** شناسه کاربر سیستم؛ اگر داده شود عکس پروفایل واقعی او نمایش داده می‌شود */
userId?: string | null;
hasAvatar?: boolean;
size?: number;
}>(),
{
name: '',
src: '/brand/user-avatar.png',
src: undefined,
userId: null,
hasAvatar: undefined,
size: 28,
},
);
watch(
() => [props.userId, props.hasAvatar] as const,
([id, has]) => {
if (id) primeUserAvatar(id, has);
},
{ immediate: true },
);
const resolvedSrc = computed(() => {
if (props.src !== undefined) return props.src;
if (props.userId) return userAvatarSrc(props.userId);
return DEFAULT_USER_AVATAR;
});
const initials = computed(() => personInitials(props.name));
const failed = ref(false);
watch(resolvedSrc, () => {
failed.value = false;
});
</script>
<template>
<span class="user-avatar" :style="{ width: `${size}px`, height: `${size}px` }" :title="name || undefined">
<img
v-if="src && !failed"
:src="src"
v-if="resolvedSrc && !failed"
:src="resolvedSrc"
:alt="name || 'آواتار'"
@error="failed = true"
/>
+2 -2
View File
@@ -89,7 +89,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
<div ref="root" class="ums" :class="{ disabled }" @keydown="onKey">
<div class="chips">
<span v-for="u in selected" :key="u.id" class="chip">
<UserAvatar :name="u.fullName" :size="22" />
<UserAvatar :name="u.fullName" :user-id="u.id" :size="22" />
<span class="chip-name">{{ u.fullName }}</span>
<button
v-if="canRemove && !disabled"
@@ -130,7 +130,7 @@ onBeforeUnmount(() => document.removeEventListener('mousedown', onDocClick));
<span class="check" aria-hidden="true">
<UiIcon v-if="modelValue.includes(u.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="u.fullName" :size="26" />
<UserAvatar :name="u.fullName" :user-id="u.id" :size="26" />
<span class="row-name">{{ u.fullName }}</span>
</button>
</li>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import UiIcon from '@/components/UiIcon.vue';
import { userAvatarSrc } from '@/lib/userAvatars';
import type { PipelineAccent, PipelineDeal, PipelineUiCopy } from '@/lib/pipeline-board';
import {
DEAL_BOARD_COPY,
@@ -35,6 +36,9 @@ const colors = computed(() => PIPELINE_ACCENT[props.accent]);
const assigneeTitle = computed(() =>
(props.deal.assigneeNames ?? []).filter(Boolean).join('، ') || 'مسئول',
);
const avatarSrc = computed(() =>
userAvatarSrc(props.deal.ownerId || props.deal.assigneeIds?.[0] || null),
);
const extraAssignees = computed(() =>
Math.max(0, (props.deal.assigneeNames?.length ?? 0) - 1),
);
@@ -70,7 +74,7 @@ const extraAssignees = computed(() =>
</button>
<img
class="avatar"
src="/brand/user-avatar.png"
:src="avatarSrc"
alt=""
width="32"
height="32"
+2
View File
@@ -188,6 +188,8 @@ export function mapBoardToStages(
stageId: deal.stageId,
currencyUnit: currencyUnitLabel(deal.currency),
assigneeNames,
ownerId: deal.owner?.id ?? null,
assigneeIds: (deal.assignees ?? []).map((a) => a.id),
};
}),
};
+96
View File
@@ -0,0 +1,96 @@
import { reactive } from 'vue';
import type { OrgUserDto } from '@mento/shared';
import { api } from '@/lib/api';
import { useAuthStore } from '@/stores/auth';
export const DEFAULT_USER_AVATAR = '/brand/user-avatar.png';
/** userId → آیا عکس دارد (از فهرست کاربران سازمان) */
const hasAvatarById = reactive<Record<string, boolean>>({});
/** userId → object URL عکس */
const urlById = reactive<Record<string, string>>({});
const pending = new Map<string, Promise<void>>();
let usersLoad: Promise<void> | null = null;
let usersScope: string | null = null;
function resetCache() {
for (const url of Object.values(urlById)) URL.revokeObjectURL(url);
for (const key of Object.keys(urlById)) delete urlById[key];
for (const key of Object.keys(hasAvatarById)) delete hasAvatarById[key];
pending.clear();
usersLoad = null;
}
function ensureScope() {
const auth = useAuthStore();
const scope = auth.user ? `${auth.user.id}:${auth.user.organizationId}` : null;
if (scope !== usersScope) {
resetCache();
usersScope = scope;
}
return auth;
}
function loadUserFlags(): Promise<void> {
if (usersLoad) return usersLoad;
usersLoad = api
.get<OrgUserDto[]>('/users')
.then(({ data }) => {
for (const u of data ?? []) hasAvatarById[u.id] = Boolean(u.hasAvatar);
})
.catch(() => undefined);
return usersLoad;
}
function fetchAvatar(userId: string): Promise<void> {
const existing = pending.get(userId);
if (existing) return existing;
const job = api
.get<Blob>(`/users/${encodeURIComponent(userId)}/avatar`, { responseType: 'blob' })
.then(({ data }) => {
if (!data || data.size === 0 || data.type.includes('json')) return;
urlById[userId] = URL.createObjectURL(data);
})
.catch(() => {
hasAvatarById[userId] = false;
});
pending.set(userId, job);
return job;
}
/** اطلاع از وضعیت عکس کاربرانی که API برگردانده (مثلاً اعضای گفتگو). */
export function primeUserAvatar(userId: string, hasAvatar: boolean | undefined) {
ensureScope();
if (hasAvatar === undefined) return;
hasAvatarById[userId] = hasAvatar;
}
/** آدرس عکس پروفایل کاربر؛ تا بارگذاری نشده عکس پیش‌فرض برمی‌گردد. */
export function userAvatarSrc(userId: string | null | undefined): string {
if (!userId) return DEFAULT_USER_AVATAR;
const auth = ensureScope();
if (auth.user?.id === userId) return auth.avatarSrc;
const url = urlById[userId];
if (url) return url;
const flag = hasAvatarById[userId];
if (flag === undefined) {
void loadUserFlags().then(() => {
if (hasAvatarById[userId]) void fetchAvatar(userId);
});
} else if (flag) {
void fetchAvatar(userId);
}
return DEFAULT_USER_AVATAR;
}
/** بعد از تغییر عکس یک کاربر (مثلاً در تنظیمات تیم) کش را تازه کن. */
export function invalidateUserAvatar(userId: string) {
const url = urlById[userId];
if (url) URL.revokeObjectURL(url);
delete urlById[userId];
delete hasAvatarById[userId];
pending.delete(userId);
usersLoad = null;
}
+1 -1
View File
@@ -329,7 +329,7 @@ onMounted(() => {
ایجاد شده توسط
</dt>
<dd class="person">
<UserAvatar v-if="activity.createdBy" :name="activity.createdBy.fullName" :size="28" />
<UserAvatar v-if="activity.createdBy" :name="activity.createdBy.fullName" :user-id="activity.createdBy.id" :size="28" />
{{ activity.createdBy?.fullName || '—' }}
</dd>
</div>
+3 -1
View File
@@ -623,6 +623,8 @@ watch([id, isNew], () => void load(), { immediate: true });
v-for="user in selectedAssignees.slice(0, 3)"
:key="user.id"
:name="user.fullName"
:user-id="user.id"
:has-avatar="user.hasAvatar"
:size="28"
/>
<UserAvatar
@@ -647,7 +649,7 @@ watch([id, isNew], () => void load(), { immediate: true });
<span class="check" :class="{ on: assigneeIds.includes(user.id) }">
<UiIcon v-if="assigneeIds.includes(user.id)" name="check-plain" :size="12" />
</span>
<UserAvatar :name="user.fullName" :size="28" />
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
{{ user.fullName }}
</button>
</li>
+16 -10
View File
@@ -296,6 +296,12 @@ function roomTitle(room: ConversationRoomDto) {
return peer?.fullName ?? room.name;
}
function roomAvatarProps(room: ConversationRoomDto) {
if (room.kind !== 'dm') return { src: '' };
const peer = peers.value.find((p) => p.id === room.peerUserId);
return { userId: room.peerUserId, hasAvatar: peer?.hasAvatar };
}
function roomKindLabel(kind: ConversationRoomDto['kind']) {
if (kind === 'channel') return 'کانال';
if (kind === 'group') return 'گروه';
@@ -1912,7 +1918,7 @@ onUnmounted(() => {
@click="openRoom(room)"
>
<span class="avatar-ring">
<UserAvatar :name="roomTitle(room)" :size="40" />
<UserAvatar v-bind="roomAvatarProps(room)" :name="roomTitle(room)" :size="40" />
</span>
<span class="room-meta">
<strong>
@@ -2019,7 +2025,7 @@ onUnmounted(() => {
<UiIcon name="arrow-right" :size="20" />
</button>
<span class="stage-avatar">
<UserAvatar :name="roomTitle(activeRoom)" :size="36" />
<UserAvatar v-bind="roomAvatarProps(activeRoom)" :name="roomTitle(activeRoom)" :size="36" />
</span>
<div class="stage-titles">
<h2>{{ roomTitle(activeRoom) }}</h2>
@@ -2274,7 +2280,7 @@ onUnmounted(() => {
:class="{ active: idx === mentionIndex }"
@click="insertMention(peer)"
>
<UserAvatar :name="peer.fullName" :size="28" />
<UserAvatar :name="peer.fullName" :user-id="peer.id" :has-avatar="peer.hasAvatar" :size="28" />
<span>{{ peer.fullName }}</span>
</button>
</div>
@@ -2521,7 +2527,7 @@ onUnmounted(() => {
:class="{ on: dmPeerId === p.id }"
@click="dmPeerId = p.id"
>
<UserAvatar :name="p.fullName" :size="40" />
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>گفتگوی خصوصی</small>
@@ -2563,7 +2569,7 @@ onUnmounted(() => {
:title="`حذف ${p.fullName}`"
@click="toggleGroupMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="22" />
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="22" />
<span>{{ p.fullName }}</span>
<UiIcon name="x" :size="12" />
</button>
@@ -2586,7 +2592,7 @@ onUnmounted(() => {
:class="{ on: groupMemberIds.includes(p.id) }"
@click="toggleGroupMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="40" />
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>{{ groupMemberIds.includes(p.id) ? 'انتخاب‌شده' : 'برای افزودن بزنید' }}</small>
@@ -2638,7 +2644,7 @@ onUnmounted(() => {
:title="`حذف ${p.fullName}`"
@click="toggleChannelMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="22" />
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="22" />
<span>{{ p.fullName }}</span>
<UiIcon name="x" :size="12" />
</button>
@@ -2661,7 +2667,7 @@ onUnmounted(() => {
:class="{ on: channelMemberIds.includes(p.id) }"
@click="toggleChannelMember(p.id)"
>
<UserAvatar :name="p.fullName" :size="40" />
<UserAvatar :name="p.fullName" :user-id="p.id" :has-avatar="p.hasAvatar" :size="40" />
<span class="peer-row-meta">
<strong>{{ p.fullName }}</strong>
<small>{{ channelMemberIds.includes(p.id) ? 'انتخاب‌شده' : 'برای افزودن بزنید' }}</small>
@@ -2853,7 +2859,7 @@ onUnmounted(() => {
<div v-if="membersBusy && !roomMembers.length" class="muted center pad">در حال بارگذاری…</div>
<div v-else class="peer-list members-list">
<div v-for="m in roomMembers" :key="m.userId" class="peer-row member-static">
<UserAvatar :name="m.fullName" :size="40" />
<UserAvatar :name="m.fullName" :user-id="m.userId" :has-avatar="m.hasAvatar" :size="40" />
<span class="peer-row-meta">
<strong>{{ m.fullName }}{{ m.isMe ? ' (شما)' : '' }}</strong>
<small>{{ m.role === 'admin' ? 'ادمین' : 'عضو' }}</small>
@@ -2904,7 +2910,7 @@ onUnmounted(() => {
:disabled="forwardBusy"
@click="confirmForward(room.externalRoomId)"
>
<UserAvatar :name="roomTitle(room)" :size="36" />
<UserAvatar v-bind="roomAvatarProps(room)" :name="roomTitle(room)" :size="36" />
<span>{{ roomTitle(room) }}</span>
</button>
</div>
+2 -2
View File
@@ -613,7 +613,7 @@ onMounted(load);
<div class="field owner-field">
<label>مسئول انجام این معامله <i>*</i></label>
<button class="picker" type="button" @click.stop="toggleMenu('owner')">
<UserAvatar :name="selectedOwner?.fullName || 'مسئول'" :size="32" />
<UserAvatar :name="selectedOwner?.fullName || 'مسئول'" :user-id="selectedOwner?.id" :size="32" />
<span :class="{ ghost: !selectedOwner }">
{{ selectedOwner?.fullName || 'انتخاب کنید' }}
</span>
@@ -622,7 +622,7 @@ onMounted(load);
<ul v-if="openMenu === 'owner'" class="menu">
<li v-for="user in users" :key="user.id">
<button type="button" @click="ownerId = user.id; openMenu = ''">
<UserAvatar :name="user.fullName" :size="28" />
<UserAvatar :name="user.fullName" :user-id="user.id" :has-avatar="user.hasAvatar" :size="28" />
{{ user.fullName }}
</button>
</li>