【新增】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 按会话类型分支换用 GroupAvatarim
parent
70e7a1c900
commit
46b06b0444
|
|
@ -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 拼图并写回 mergedUrl;mergeToken 校验避免老 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>
|
||||||
Loading…
Reference in New Issue