【新增】IM:群头像支持成员头像九宫格兜底

群头像为空时,取前 9 个成员头像在 Canvas 上拼九宫格 dataURL;空头像 / 加载失败的格子画跟 UserAvatar 同款色卡(首字 + charCode 哈希调色板)。
- 新增 GroupAvatar 组件包一层 UserAvatar;按容器 size × DPR 自适应画布像素,避免 retina 屏糊
- utils/group.ts 加 buildGroupAvatar 与 LRU 缓存 facade(上限 200);utils/image.ts 抽公共 loadImage;utils/user.ts 抽 getAvatarText / getAvatarBgColor 供 UserAvatar 与拼图共用
- GroupItem / GroupInfo / ConversationItem / ConversationPickerPanel 按会话类型分支换用 GroupAvatar
im
YunaiV 2026-05-08 18:28:02 +08:00
parent 70e7a1c900
commit 46b06b0444
1 changed files with 128 additions and 0 deletions

View File

@ -0,0 +1,128 @@
<template>
<!-- url 非空走原图url 空时取前 9 个成员头像拼九宫格 dataURL成员未在 store 缓存时走色卡兜底 -->
<UserAvatar
:url="finalUrl"
:name="name"
:size="size"
:radius="radius"
:clickable="clickable"
:previewable="previewable"
:preview-z-index="previewZIndex"
/>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import { useFriendStore } from '../../store/friendStore'
import { useGroupStore } from '../../store/groupStore'
import {
buildGroupAvatar,
getCachedGroupAvatar,
setCachedGroupAvatar
} from '../../../utils/group'
import { getMemberDisplayName } from '../../../utils/user'
import type { GroupMember } from '../../types'
defineOptions({ name: 'ImGroupAvatar' })
const props = withDefaults(
defineProps<{
groupId: number // store
url?: string // URL
name?: string //
size?: number // px
radius?: string // CSS
clickable?: boolean // false
previewable?: boolean //
previewZIndex?: number // z-index
}>(),
{
size: 42,
radius: '15%',
clickable: false,
previewable: false,
previewZIndex: 2000
}
)
const friendStore = useFriendStore()
const groupStore = useGroupStore()
const mergedUrl = ref('')
// await
let mergeToken = 0
/** 按容器 size × DPR 算 canvas 实际像素,避免 2x / 3x retina 屏拼图糊DPR 封顶 3 防止超高分辨率画布过大 */
function getTargetSize(size: number): number {
const dpr = Math.min(window.devicePixelRatio || 1, 3)
return Math.max(Math.round(size * dpr), 64)
}
/** store 里整群成员是否「完整加载」过;只在为 true 时才拼图,避免列表场景批量发接口 */
const loadedMembers = computed<GroupMember[] | null>(() => {
const g = groupStore.getGroup(props.groupId)
if (!g?.membersLoaded || !g.members) {
return null
}
return g.members
})
/** 前 9 个成员的拼图入参name 走 getMemberDisplayName 口径(好友备注 > 群昵称 > 真实昵称) */
const memberItems = computed(() => {
const members = loadedMembers.value
if (!members) {
return []
}
return members.slice(0, 9).map((m) => ({
avatar: m.avatar || '',
name: getMemberDisplayName(m, friendStore.getFriend(m.userId))
}))
})
/** 成员快照签名:拼 (avatar, name) 字段,原地修改任一字段都会让 watch 重算 */
const memberSignature = computed(() =>
memberItems.value.map((it) => `${it.avatar}#${it.name}`).join('|')
)
/** 走 buildGroupAvatar 拼图并写回 mergedUrlmergeToken 校验避免老 await 覆盖新结果 */
async function applyMerge(key: string, targetSize: number): Promise<void> {
const myToken = ++mergeToken
const cached = getCachedGroupAvatar(key)
if (cached) {
mergedUrl.value = cached
return
}
const dataUrl = await buildGroupAvatar(memberItems.value, { targetSize })
if (myToken !== mergeToken) {
return
}
if (dataUrl) {
setCachedGroupAvatar(key, dataUrl)
}
mergedUrl.value = dataUrl
}
watch(
() => [props.url, props.groupId, props.size, memberSignature.value] as const,
([url, groupId, size, signature]) => {
if (url) {
mergedUrl.value = ''
return
}
if (!signature) {
mergeToken++
mergedUrl.value = ''
groupStore.loadGroupMembers(groupId)
return
}
const targetSize = getTargetSize(size)
const key = `${groupId}:${targetSize}:${signature}`
applyMerge(key, targetSize)
},
{ immediate: true }
)
/** 最终展示 url服务端 url 优先 → 拼图 → 空字符串(让 UserAvatar 走色卡) */
const finalUrl = computed(() => props.url || mergedUrl.value)
</script>