Entity Avatar
一个东西的唯一图像——字幕组的 logo、用户的头像——和唯一的兜底规则:有图显示图,没图显示按文字类型取的首字。
- 喵萌奶茶屋字幕组 · 有 logo
- LoliHouse字幕组 · 两个拉丁字母
- 北宇治字幕组字幕组 · 小尺寸只放一个汉字
- 栞 Shiori用户 · 圆形
entity-avatar-demo.tsx
import { EntityAvatar } from "@/components/ui/entity-avatar"
const ROWS = [
{ name: "喵萌奶茶屋", src: "/demo/logos/paw.svg", shape: "rounded" as const, meta: "字幕组 · 有 logo" },
{ name: "LoliHouse", src: null, shape: "rounded" as const, meta: "字幕组 · 两个拉丁字母" },
{ name: "北宇治字幕组", src: null, shape: "rounded" as const, meta: "字幕组 · 小尺寸只放一个汉字" },
{ name: "栞 Shiori", src: null, shape: "circle" as const, meta: "用户 · 圆形" },
]
export default function EntityAvatarDemo() {
return (
<div className="flex w-full max-w-md flex-col gap-5">
<ul className="flex flex-col">
{ROWS.map((row) => (
<li key={row.name} className="anibt-stitch-under flex items-center gap-3 py-2.5 last:bg-none">
<EntityAvatar name={row.name} src={row.src} shape={row.shape} />
<div className="min-w-0">
<div className="anibt-ell text-[14px] font-semibold">{row.name}</div>
<div className="text-[12px] text-muted-foreground">{row.meta}</div>
</div>
</li>
))}
</ul>
<div className="flex items-end gap-4">
<EntityAvatar name="北宇治字幕组" shape="rounded" size="sm" />
<EntityAvatar name="北宇治字幕组" shape="rounded" />
<EntityAvatar name="北宇治字幕组" shape="rounded" size="lg" />
<EntityAvatar name="北宇治字幕组" shape="rounded" size="xl" />
<EntityAvatar name="喵萌奶茶屋" src="/demo/logos/paw.svg" shape="rounded" size="xl" priority />
</div>
</div>
)
}安装
npx shadcn@latest add @anibt/entity-avatar第一次用之前:在 components.json 里登记 @anibt,再单独运行一次 npx shadcn@latest add @anibt/theme(见快速开始)。只有单独安装主题时,CLI 才会把 shadcn 的标准颜色换成白樱 / 夜樱;随组件带进来的主题只补上 AniBT 自己的变量和配方。 CLI 会一并装好 @anibt/avatar。
手动安装
- 1. 先装主题组件只读
@anibt/theme的 token 和配方,没有主题就没有样子。npx shadcn@latest add @anibt/theme - 2. 它还用到
npx shadcn@latest add @anibt/avatar - 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),表格、提示里需要同一套首字规则时直接用。
