Profile picture display has been standardized across the board.
This commit is contained in:
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user