【新增】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:27:53 +08:00
parent c5b082ca80
commit 70e7a1c900
8 changed files with 398 additions and 36 deletions

View File

@ -7,11 +7,11 @@
-->
<div class="flex justify-center">
<div class="w-full max-w-[320px] flex flex-col gap-3 items-center">
<UserAvatar
<GroupAvatar
:group-id="group.id"
:url="group.showImage || group.showImageThumb"
:name="group.showGroupName || group.name"
:size="72"
:clickable="false"
:previewable="isMember"
/>
<div
@ -47,7 +47,7 @@
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import UserAvatar from '../user/UserAvatar.vue'
import GroupAvatar from './GroupAvatar.vue'
import GroupMemberGrid from './GroupMemberGrid.vue'
import { useUserStore } from '@/store/modules/user'
import { CommonStatusEnum } from '@/utils/constants'

View File

@ -9,11 +9,11 @@
:class="{ '!bg-[#d9ecff] dark:!bg-[var(--el-color-primary-light-8)]': active }"
@click="$emit('click', group)"
>
<UserAvatar
<GroupAvatar
:group-id="group.id"
:url="group.showImage || group.showImageThumb"
:name="group.showGroupName || group.name"
:size="42"
:clickable="false"
/>
<div class="flex flex-1 min-w-0">
<!-- 单行展示群名成员数仅在群详情面板展示列表里不重复 -->
@ -26,7 +26,7 @@
</template>
<script lang="ts" setup>
import UserAvatar from '../user/UserAvatar.vue'
import GroupAvatar from './GroupAvatar.vue'
import type { GroupLite } from '../../types'
defineOptions({ name: 'ImGroupItem' })

View File

@ -43,7 +43,15 @@
@click="handleRecentTileClick(conversation)"
>
<div class="relative">
<GroupAvatar
v-if="conversation.type === ImConversationType.GROUP"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="36"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="36"
@ -124,7 +132,15 @@
color="#fff"
/>
</span>
<GroupAvatar
v-if="conversation.type === ImConversationType.GROUP"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="32"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="32"
@ -163,7 +179,15 @@
:key="getConversationKey(conversation)"
class="flex gap-2.5 items-center px-4 py-2"
>
<GroupAvatar
v-if="conversation.type === ImConversationType.GROUP"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="32"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="32"
@ -206,7 +230,9 @@ import Icon from '@/components/Icon/src/Icon.vue'
import { useMessage } from '@/hooks/web/useMessage'
import UserAvatar from '../user/UserAvatar.vue'
import GroupAvatar from '../group/GroupAvatar.vue'
import { filterConversationsByKeyword, getConversationKey } from '../../../utils/conversation'
import { ImConversationType } from '../../../utils/constants'
import type { Conversation } from '../../types'
defineOptions({ name: 'ImConversationPickerPanel' })

View File

@ -46,6 +46,7 @@ import { computed } from 'vue'
import { useImUiStore } from '../../store/uiStore'
import { ImFriendAddSource } from '../../../utils/constants'
import { getAvatarBgColor, getAvatarText } from '../../../utils/user'
import type { User } from '../../types'
defineOptions({ name: 'ImUserAvatar', inheritAttrs: false })
@ -90,35 +91,11 @@ const textStyle = computed(() => ({
borderRadius: props.radius
}))
/** 色卡首字:中文取 1 个字;英文/拉丁取前 2 个字母(跳过数字、空格、符号) */
const avatarText = computed(() => {
const trimmed = props.name?.trim()
if (!trimmed) {
return ''
}
const first = trimmed.charAt(0)
const code = first.charCodeAt(0)
if (code >= 0x4e00 && code <= 0x9fa5) {
return first
}
const letters = trimmed.match(/[A-Za-z]/g)
if (!letters || letters.length === 0) {
return first.toUpperCase()
}
return letters.slice(0, 2).join('').toUpperCase()
})
/** 色卡首字:中文取 1 个字、英文 / 拉丁取前 2 个字母 */
const avatarText = computed(() => getAvatarText(props.name))
const colors = ['#07C160', '#1A95FF', '#FA9D3B', '#9163E0', '#F76760', '#1ABC9C'] //
const textColor = computed(() => {
if (!props.name) {
return '#909399'
}
let hash = 0
for (let i = 0; i < props.name.length; i++) {
hash += props.name.charCodeAt(i)
}
return colors[hash % colors.length]
})
/** 色卡底色:按昵称 charCode 哈希取调色板色 */
const textColor = computed(() => getAvatarBgColor(props.name))
/** 头像点击previewable 走 el-image 预览不弹名片;否则按 user / id 任一入参打开名片 */
function handleClick(e: MouseEvent) {

View File

@ -7,7 +7,15 @@
>
<!-- 头像 + 未读徽标免打扰会话不显示徽标 -->
<div class="relative">
<GroupAvatar
v-if="isGroup"
:group-id="conversation.targetId"
:url="conversation.avatar"
:name="conversation.name"
:size="40"
/>
<UserAvatar
v-else
:url="conversation.avatar"
:name="conversation.name"
:size="40"
@ -90,6 +98,7 @@ import { getSenderDisplayName } from '@/views/im/utils/user'
import { buildRecallTip } from '@/views/im/utils/conversation'
import type { Conversation } from '../../../../types'
import UserAvatar from '../../../../components/user/UserAvatar.vue'
import GroupAvatar from '../../../../components/group/GroupAvatar.vue'
defineOptions({ name: 'ImConversationItem' })

View File

@ -1,4 +1,6 @@
import type { FriendLite } from '../home/types'
import { loadImage } from './image'
import { getAvatarBgColor, getAvatarText } from './user'
/** 默认群名生成:所选好友前 4 个名字拼接,超过补「等 N 人」;为空兜底「群聊」 */
export function buildDefaultGroupName(members: FriendLite[]): string {
@ -12,3 +14,300 @@ export function buildDefaultGroupName(members: FriendLite[]): string {
}
return head || '群聊'
}
/** 群头像单格的输入:头像 URL + 名字 ,至少给一个,二者都缺时走灰底空格 */
export interface GroupAvatarMember {
/** 头像 URL缺失或加载失败时按 name 画色卡 */
avatar?: string
/** 显示名(昵称 / 备注);色卡文字 + 底色 hash 来源 */
name?: string
}
/** 群头像拼接的可选参数 */
export interface BuildGroupAvatarOptions {
/** 输出画布边长(像素);默认 64 */
targetSize?: number
/** 单格之间的间隔(像素);默认 1 */
divider?: number
/** 画布底色;默认透明,让上下留白透出宿主容器底色 */
background?: string
}
/** 单格在画布上的位置 + 尺寸 */
interface CellRect {
x: number
y: number
w: number
h: number
}
/** 单格的最终绘制内容 */
type Cell =
| { kind: 'image'; img: HTMLImageElement }
| { kind: 'color'; text: string; bg: string }
/**
* dataURL 1 ~ 9
*
* - 9
* - avatar / name canvas
* - CORSimg.crossOrigin = 'anonymous'
*/
export async function buildGroupAvatar(
members: GroupAvatarMember[],
options: BuildGroupAvatarOptions = {}
): Promise<string> {
const targetSize = options.targetSize ?? 64
const divider = options.divider ?? 1
const background = options.background
const top = members.slice(0, 9)
if (top.length === 0) {
return ''
}
const cells = await Promise.all(top.map((m) => resolveCell(m)))
const rects = computeCellRects(cells.length, targetSize, divider)
const canvas = document.createElement('canvas')
canvas.width = targetSize
canvas.height = targetSize
const ctx = canvas.getContext('2d')
if (!ctx) {
return ''
}
if (background) {
ctx.fillStyle = background
ctx.fillRect(0, 0, targetSize, targetSize)
}
cells.forEach((cell, idx) => {
const rect = rects[idx]
if (!rect) {
return
}
drawCell(ctx, rect, cell)
})
return canvas.toDataURL('image/png')
}
/** 群头像 dataURL 缓存上限:每条约 5 ~ 30KB200 条软封顶约 5MB 常驻 */
const MERGED_AVATAR_CACHE_MAX = 200
const mergedAvatarCache = new Map<string, string>()
/** 取群头像 dataURL 缓存;命中时按 LRU 提到末尾 */
export function getCachedGroupAvatar(key: string): string | undefined {
const cached = mergedAvatarCache.get(key)
if (cached === undefined) {
return undefined
}
mergedAvatarCache.delete(key)
mergedAvatarCache.set(key, cached)
return cached
}
/** 写群头像 dataURL 缓存超上限时丢弃最早一条Map 迭代顺序 = 插入顺序,配合 get 的提升即 LRU */
export function setCachedGroupAvatar(key: string, value: string): void {
if (mergedAvatarCache.has(key)) {
mergedAvatarCache.delete(key)
} else if (mergedAvatarCache.size >= MERGED_AVATAR_CACHE_MAX) {
const oldest = mergedAvatarCache.keys().next().value
if (oldest !== undefined) {
mergedAvatarCache.delete(oldest)
}
}
mergedAvatarCache.set(key, value)
}
/** 单成员 → Cell有 avatar 且加载成功走 image否则走 color */
async function resolveCell(member: GroupAvatarMember): Promise<Cell> {
if (member.avatar) {
const img = await loadImage(member.avatar)
if (img) {
return { kind: 'image', img }
}
}
return {
kind: 'color',
text: getAvatarText(member.name),
bg: getAvatarBgColor(member.name)
}
}
/** 把单个 Cell 画到 ctx 上指定格子里image 走 cover 裁剪保比例color 走 fillRect + 居中文字 */
function drawCell(
ctx: CanvasRenderingContext2D,
rect: CellRect,
cell: Cell
): void {
const { x, y, w, h } = rect
if (cell.kind === 'image') {
drawImageCover(ctx, cell.img, x, y, w, h)
return
}
ctx.fillStyle = cell.bg
ctx.fillRect(x, y, w, h)
if (!cell.text) {
return
}
// 字号按短边算;单字 0.42、双字 0.34
const baseSize = Math.min(w, h)
const fontSize = Math.floor(baseSize * (cell.text.length > 1 ? 0.34 : 0.42))
ctx.fillStyle = '#FFFFFF'
ctx.font = `500 ${fontSize}px -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(cell.text, x + w / 2, y + h / 2)
}
/** cover 裁剪:从源图中心裁出与目标矩形同比例的子区域,画到目标矩形(保持比例不变形) */
function drawImageCover(
ctx: CanvasRenderingContext2D,
img: HTMLImageElement,
dx: number,
dy: number,
dw: number,
dh: number
): void {
const srcAspect = img.width / img.height
const dstAspect = dw / dh
let sx = 0
let sy = 0
let sw = img.width
let sh = img.height
if (srcAspect > dstAspect) {
sw = sh * dstAspect
sx = (img.width - sw) / 2
} else if (srcAspect < dstAspect) {
sh = sw / dstAspect
sy = (img.height - sh) / 2
}
ctx.drawImage(img, sx, sy, sw, sh, dx, dy, dw, dh)
}
/**
* +
*
* - 1
* - 2~4 2 2
* - 5~6 3 2 +
* - 7~9 3 3
*/
function computeCellRects(count: number, target: number, divider: number): CellRect[] {
if (count <= 0) {
return []
}
if (count === 1) {
return [{ x: 0, y: 0, w: target, h: target }]
}
if (count <= 4) {
return computeCellRectsSmall(count, target, divider)
}
if (count <= 6) {
return computeCellRectsMedium(count, target, divider)
}
return computeCellRectsLarge(count, target, divider)
}
/** 2~4 张2 列 2 行正方形 */
function computeCellRectsSmall(count: number, target: number, divider: number): CellRect[] {
const s = (target - 3 * divider) / 2
const step = s + divider
const rects: CellRect[] = []
switch (count) {
case 2: {
// 居中 1 行(留上下空白)
const y = target / 4
rects.push({ x: divider, y, w: s, h: s })
rects.push({ x: divider + step, y, w: s, h: s })
break
}
case 3: {
// 上 1 居中 + 下 2
rects.push({ x: target / 4, y: divider, w: s, h: s })
const y1 = divider + step
rects.push({ x: divider, y: y1, w: s, h: s })
rects.push({ x: divider + step, y: y1, w: s, h: s })
break
}
case 4: {
// 2×2 满铺
rects.push({ x: divider, y: divider, w: s, h: s })
rects.push({ x: divider + step, y: divider, w: s, h: s })
const y1 = divider + step
rects.push({ x: divider, y: y1, w: s, h: s })
rects.push({ x: divider + step, y: y1, w: s, h: s })
break
}
}
return rects
}
/** 5~6 张3 列 2 行正方形单格 + 垂直居中;上下留白由透明背景透出 GroupAvatar 容器底色 */
function computeCellRectsMedium(count: number, target: number, divider: number): CellRect[] {
const s = (target - 4 * divider) / 3
const step = s + divider
const xs = [divider, divider + step, divider + 2 * step]
// 2 行高 + 1 行间 div
const totalH = 2 * s + divider
const y0 = (target - totalH) / 2
const y1 = y0 + step
const rects: CellRect[] = []
if (count === 5) {
// 上 2 居中(左右对称留白) + 下 3 左右贴边
const upX0 = (target - 2 * s - divider) / 2
rects.push({ x: upX0, y: y0, w: s, h: s })
rects.push({ x: upX0 + step, y: y0, w: s, h: s })
rects.push({ x: xs[0], y: y1, w: s, h: s })
rects.push({ x: xs[1], y: y1, w: s, h: s })
rects.push({ x: xs[2], y: y1, w: s, h: s })
return rects
}
// count === 6上 3 + 下 3 满铺
rects.push({ x: xs[0], y: y0, w: s, h: s })
rects.push({ x: xs[1], y: y0, w: s, h: s })
rects.push({ x: xs[2], y: y0, w: s, h: s })
rects.push({ x: xs[0], y: y1, w: s, h: s })
rects.push({ x: xs[1], y: y1, w: s, h: s })
rects.push({ x: xs[2], y: y1, w: s, h: s })
return rects
}
/** 7~9 张3 列 3 行正方形单格 */
function computeCellRectsLarge(count: number, target: number, divider: number): CellRect[] {
const s = (target - 4 * divider) / 3
const step = s + divider
const xs = [divider, divider + step, divider + 2 * step]
const ys = [divider, divider + step, divider + 2 * step]
const rects: CellRect[] = []
if (count === 7) {
// 上 1 居中 + 中 3 + 下 3
rects.push({ x: xs[1], y: ys[0], w: s, h: s })
rects.push({ x: xs[0], y: ys[1], w: s, h: s })
rects.push({ x: xs[1], y: ys[1], w: s, h: s })
rects.push({ x: xs[2], y: ys[1], w: s, h: s })
rects.push({ x: xs[0], y: ys[2], w: s, h: s })
rects.push({ x: xs[1], y: ys[2], w: s, h: s })
rects.push({ x: xs[2], y: ys[2], w: s, h: s })
return rects
}
if (count === 8) {
// 上 2 居中 + 中 3 + 下 3
const upX0 = (target - 2 * s - divider) / 2
rects.push({ x: upX0, y: ys[0], w: s, h: s })
rects.push({ x: upX0 + step, y: ys[0], w: s, h: s })
rects.push({ x: xs[0], y: ys[1], w: s, h: s })
rects.push({ x: xs[1], y: ys[1], w: s, h: s })
rects.push({ x: xs[2], y: ys[1], w: s, h: s })
rects.push({ x: xs[0], y: ys[2], w: s, h: s })
rects.push({ x: xs[1], y: ys[2], w: s, h: s })
rects.push({ x: xs[2], y: ys[2], w: s, h: s })
return rects
}
// count === 93×3 满铺
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
rects.push({ x: xs[col], y: ys[row], w: s, h: s })
}
}
return rects
}

View File

@ -1,12 +1,30 @@
// ====================================================================
// IM 图片探针 utility
// IM 图片工具
// ====================================================================
// 加载本地 File 或远程 URL读出 naturalWidth / naturalHeight
// - probeImageSize加载本地 File 或远程 URL读出 naturalWidth / naturalHeight
// - loadImage加载远程 URL 到 HTMLImageElement失败返回 null不抛错供 canvas 绘制使用
// ====================================================================
/** 默认占位尺寸probe 失败 / 解码异常时兜底,避免 width/height 为 0 让消息渲染塌掉 */
const DEFAULT_FALLBACK_SIZE = { width: 200, height: 200 } as const
/** 加载远程 URL 到 HTMLImageElement失败返回 nullcanvas 绘制要求 crossOrigin=anonymous默认开 */
export function loadImage(
src: string,
options: { crossOrigin?: 'anonymous' | 'use-credentials' | null } = {}
): Promise<HTMLImageElement | null> {
const crossOrigin = options.crossOrigin === undefined ? 'anonymous' : options.crossOrigin
return new Promise((resolve) => {
const img = new Image()
if (crossOrigin) {
img.crossOrigin = crossOrigin
}
img.onload = () => resolve(img)
img.onerror = () => resolve(null)
img.src = src
})
}
/**
* File URL naturalWidth / naturalHeight
*

View File

@ -499,3 +499,36 @@ export function getGenderColor(sex?: number): string {
}
return ''
}
/** 头像色卡底色调色板(参考微信) */
const AVATAR_BG_COLORS = ['#07C160', '#1A95FF', '#FA9D3B', '#9163E0', '#F76760', '#1ABC9C']
/** 头像色卡文字:中文取首字、英文取前 2 字母大写、其他取首字大写、空名返回空串 */
export function getAvatarText(name?: string): string {
const trimmed = name?.trim()
if (!trimmed) {
return ''
}
const first = trimmed.charAt(0)
const code = first.charCodeAt(0)
if (code >= 0x4e00 && code <= 0x9fa5) {
return first
}
const letters = trimmed.match(/[A-Za-z]/g)
if (!letters || letters.length === 0) {
return first.toUpperCase()
}
return letters.slice(0, 2).join('').toUpperCase()
}
/** 头像色卡底色:按 name charCode 之和取调色板色,空名走默认灰 */
export function getAvatarBgColor(name?: string): string {
if (!name) {
return '#909399'
}
let hash = 0
for (let i = 0; i < name.length; i++) {
hash += name.charCodeAt(i)
}
return AVATAR_BG_COLORS[hash % AVATAR_BG_COLORS.length]
}