AniBTUI
✦ 组件@anibt/entity-avatarregistry JSON

Entity Avatar

一个东西的唯一图像——字幕组的 logo、用户的头像——和唯一的兜底规则:有图显示图,没图显示按文字类型取的首字。

  • 喵萌奶茶屋
    字幕组 · 有 logo
  • LoliHouse
    字幕组 · 两个拉丁字母
  • 北宇治字幕组
    字幕组 · 小尺寸只放一个汉字
  • 栞 Shiori
    用户 · 圆形

安装

npx shadcn@latest add @anibt/entity-avatar

第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/avatar。

手动安装
  1. 1. 先装主题组件只读 @anibt/theme 的 token 和配方,没有主题就没有样子。
    npx shadcn@latest add @anibt/theme
  2. 2. 它还用到
    npx shadcn@latest add @anibt/avatar
  3. 3. 把下面的文件放进项目
    components/ui/entity-avatar.tsx
    "use client"
    
    import * as React from "react"
    
    import { Avatar, AvatarFallback, AvatarImage, type AvatarProps } from "@/components/ui/avatar"
    
    /**
     * EntityAvatar — the one picture of a thing (a subtitle group's logo, a
     * user's portrait) with the one fallback rule.
     *
     * Show the real image when there is one; otherwise the initials, in the round
     * face on the pink wash, inside the same die-cut sticker. Never a hashed
     * gradient, never initials painted over a logo that exists.
     *
     * Initials follow the script: two Latin letters fit any size, but a Han, kana
     * or hangul glyph is as wide as it is tall, so the small sizes (`xs`, `sm`,
     * `default`) take one and the large ones take two —「喵萌」reads better than
     * 「喵」on a profile head, and「喵萌」clipped on both sides in a 24px circle
     * reads as nothing.
     */
    type EntityAvatarSize = NonNullable<AvatarProps["size"]>
    
    type EntityAvatarProps = Omit<AvatarProps, "children"> & {
      /** Display name: the alt text and the source of the initials. */
      name: string
      /** Image URL. Empty means "no image" and the initials take over. */
      src?: string | null
      /** `srcSet` for the image; give a 3× source for sharp edges on phones. */
      srcSet?: string
      /** Overrides the derived initials (a single letter, an emoji). */
      initials?: string
      /** Loads the image eagerly: a header above the fold. */
      priority?: boolean
      fallbackClassName?: string
    }
    
    const SIZE_PX: Record<EntityAvatarSize, number> = { xs: 20, sm: 24, default: 32, lg: 40, xl: 64 }
    
    /** How many wide (CJK) glyphs each size has room for. */
    const WIDE_GLYPHS: Record<EntityAvatarSize, number> = { xs: 1, sm: 1, default: 1, lg: 2, xl: 2 }
    
    const WIDE_SCRIPT = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u
    
    /** Initials for a name, by the rule above. `?` for an empty name. */
    function getEntityInitials(name: string, wideGlyphs = 1): string {
      const glyphs = [...name.trim()]
      if (glyphs.length === 0) return "?"
      const limit = WIDE_SCRIPT.test(glyphs[0] ?? "") ? wideGlyphs : 2
      return glyphs.slice(0, limit).join("").toUpperCase()
    }
    
    function EntityAvatar({
      name,
      src,
      srcSet,
      initials,
      priority = false,
      size,
      shape = "circle",
      className,
      fallbackClassName,
      ...props
    }: EntityAvatarProps) {
      const resolved = size ?? "default"
      const text = initials ?? getEntityInitials(name, WIDE_GLYPHS[resolved])
    
      return (
        <Avatar
          data-entity="true"
          size={resolved}
          shape={shape}
          title={name}
          className={className}
          {...props}
        >
          {src ? (
            <AvatarImage
              src={src}
              srcSet={srcSet}
              // Width descriptors need the rendered width, or the browser assumes
              // 100vw and fetches the largest file for a 32px circle.
              sizes={srcSet ? `${SIZE_PX[resolved]}px` : undefined}
              alt={name}
              width={SIZE_PX[resolved]}
              height={SIZE_PX[resolved]}
              loading={priority ? "eager" : "lazy"}
              fetchPriority={priority ? "high" : undefined}
              decoding="async"
            />
          ) : null}
          {/* Shown with no image, before it loads and when it fails: the name
              goes to assistive technology, the letters only to the eye. */}
          <AvatarFallback role="img" aria-label={name} className={fallbackClassName}>
            <span aria-hidden="true">{text}</span>
          </AvatarFallback>
        </Avatar>
      )
    }
    
    export { EntityAvatar, getEntityInitials }
    export type { EntityAvatarProps }

用法

用法
import { EntityAvatar } from "@/components/ui/entity-avatar"

<EntityAvatar name="喵萌奶茶屋" src={group.logoUrl} shape="rounded" />
<EntityAvatar name={user.name} src={user.image} size="lg" />

兜底规则

  • 有图就显示图,加载中和加载失败时才显示首字;不会在已有的 logo 上再画首字,也不会用哈希渐变当头像。
  • 首字按文字类型取:拉丁字母取两个(LoliHouse → LO),在任何尺寸都放得下;汉字、假名、谚文和字一样宽,xs / sm / default 只放一个(「北」),lg / xl 放两个(「北宇」)。
  • initials 可以直接指定(一个字母、一个 emoji)。
  • 屏幕阅读器读到的是完整的 name,不是首字。

图片会按尺寸写好 width / height;要在 3× 屏上也锐利,给 srcSet 一张至少三倍尺寸的图(32px 头像要 96w 以上)。首屏的头像加 priority。

属性

EntityAvatar
namestring默认 —必填:名字,也是 alt 文本和首字来源
srcstring | null默认 —图片地址;为空时显示首字
srcSetstring默认 —多倍图,3× 屏也锐利
initialsstring默认 —覆盖自动取的首字
priorityboolean默认 false首屏图片:立即加载、高优先级
size"xs" | "sm" | "default" | "lg" | "xl"默认 "default"同 Avatar
shape"circle" | "rounded"默认 "circle"同 Avatar
fallbackClassNamestring默认 —首字底色、字号的覆盖

另外导出 getEntityInitials(name, wideGlyphs),表格、提示里需要同一套首字规则时直接用。