gaoluyang
2026-06-29 27cd042df9aca0383a49f3514bc21958dd890912
src/views/im/utils/group.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,289 @@
import type { FriendLite } from '../home/types'
import { loadImage } from './image'
import { getAvatarBgColor, getAvatarText } from './user'
/** é»˜è®¤ç¾¤åç”Ÿæˆï¼šæ‰€é€‰å¥½å‹å‰ 4 ä¸ªåå­—拼接,超过补「等 N äººã€ï¼›ä¸ºç©ºå…œåº•「群聊」 */
export function buildDefaultGroupName(members: FriendLite[]): string {
  if (members.length === 0) {
    return '群聊'
  }
  const names = members.slice(0, 4).map((m) => m.displayName || m.nickname || '')
  const head = names.filter(Boolean).join('、')
  if (members.length > 4) {
    // members åªå«è¢«é€‰å¥½å‹ï¼Œ+1 æŠŠåˆ›å»ºè€…也计入实际成员数
    return `${head}等${members.length + 1}人`
  }
  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 =
  | { bg: string; kind: 'color'; text: string; }
  | { img: HTMLImageElement; kind: 'image'; }
/**
 * æŠŠç¾¤æˆå‘˜å¤´åƒæ‹¼æˆä¸€å¼ æ–¹å½¢ç¾¤å¤´åƒ dataURL,按 1 ~ 9 å¼ èµ°ä¹å®«æ ¼å˜ä½“布局
 *
 * - ä»…取前 9 ä¸ªæˆå‘˜
 * - å•æ ¼ avatar ä¸ºç©º / åŠ è½½å¤±è´¥ï¼šæŒ‰ name åœ¨ canvas ä¸Šç”»è‰²å¡å…œåº•
 * - è·¨åŸŸè¦æ±‚图片源开启 CORS(img.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 ~ 30KB,200 æ¡è½¯å°é¡¶çº¦ 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 }, { 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 }, { x: divider + step, y: y1, w: s, h: s })
      break
    }
    case 4: {
      // 2×2 æ»¡é“º
      rects.push({ x: divider, y: divider, w: s, h: s }, { x: divider + step, y: divider, w: s, h: s })
      const y1 = divider + step
      rects.push({ x: divider, y: y1, w: s, h: s }, { 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] as const
  // 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 }, { x: upX0 + step, y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { 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 }, { x: xs[1], y: y0, w: s, h: s }, { x: xs[2], y: y0, w: s, h: s }, { x: xs[0], y: y1, w: s, h: s }, { x: xs[1], y: y1, w: s, h: s }, { 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] as const
  const ys = [divider, divider + step, divider + 2 * step] as const
  const rects: CellRect[] = []
  if (count === 7) {
    // ä¸Š 1 å±…中 + ä¸­ 3 + ä¸‹ 3
    rects.push({ x: xs[1], y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { 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 }, { x: upX0 + step, y: ys[0], w: s, h: s }, { x: xs[0], y: ys[1], w: s, h: s }, { x: xs[1], y: ys[1], w: s, h: s }, { x: xs[2], y: ys[1], w: s, h: s }, { x: xs[0], y: ys[2], w: s, h: s }, { x: xs[1], y: ys[2], w: s, h: s }, { x: xs[2], y: ys[2], w: s, h: s })
    return rects
  }
  // count === 9:3×3 æ»¡é“º
  for (const y of ys) {
    for (const x of xs) {
      rects.push({ x, y, w: s, h: s })
    }
  }
  return rects
}