Files
BYW-VoiceHub/server/utils/user-avatar.ts
T
Henry_Du d09d12828d
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 55s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
feat(admin): 优化用户权限分级·统一头像·数据分析面板修复
- fix(DataAnalysisPanel): 活跃用户卡片固定 400px + overflow-y 独立滚动容器,底部渐变遮罩提示滚动;列表项 tooltip 增加 max-height 与 overflow 截断;新增圆形头像显示与 @error 回退首字母
- feat(admin): 新增 getUserAvatarUrl 统一头像策略链(OAuth identity→Gravatar→DiceBear 7.x initials),stats/realtime、active-users、users/index 三处接口返回 avatarUrl
- fix(admin): 建立 SUPER_ADMIN(100)>ADMIN(80)>USER(10) 分级常量与 canChangeRoleOf/canResetPasswordFor 等谓词;reset-password、[id].put、index.post 全面应用级别守卫;前端 UserManager 按钮按权限禁用、availableRoles 按操作者级别过滤 SUPER_ADMIN/ADMIN 等高角色
2026-09-12 08:36:45 +08:00

123 lines
3.4 KiB
TypeScript

interface AvatarUserState {
avatarProvider?: string | null
avatarProviderUserId?: string | null
email?: string | null
username?: string | null
name?: string | null
id?: string | number | null
}
interface IdentityRow {
provider: string
providerUserId: string
avatar?: string | null
createdAt: Date | string
providerUsername?: string | null
}
export interface AvatarSource {
provider: string | null
providerUserId: string | null
}
export function resolveAvatarSource(
user: AvatarUserState,
identities: IdentityRow[]
): AvatarSource | null {
if (user.avatarProvider) {
return {
provider: user.avatarProvider,
providerUserId: user.avatarProviderUserId ?? null
}
}
const sorted = [...identities].sort((a, b) => {
const at = new Date(a.createdAt).getTime()
const bt = new Date(b.createdAt).getTime()
return at - bt
})
for (const identity of sorted) {
if (identity.avatar) {
return {
provider: identity.provider,
providerUserId: identity.providerUserId
}
}
}
return null
}
function md5Hash(str: string): string {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = ((hash << 5) - hash) + char
hash = hash & hash
}
return Math.abs(hash).toString(16).padStart(8, '0') + Math.abs(hash * 7).toString(16).padStart(8, '0') +
Math.abs(hash * 13).toString(16).padStart(8, '0') + Math.abs(hash * 17).toString(16).padStart(8, '0')
}
export function generateGravatarUrl(email?: string | null): string | null {
if (!email) return null
const normalizedEmail = email.trim().toLowerCase()
const hash = md5Hash(normalizedEmail)
return `https://www.gravatar.com/avatar/${hash}?d=404&s=128`
}
export function generateDiceBearUrl(seed: string): string {
const encodedSeed = encodeURIComponent(seed || 'user')
return `https://api.dicebear.com/7.x/initials/svg?seed=${encodedSeed}&backgroundType=gradientLinear&fontSize=40&fontWeight=700`
}
export function buildAvatarFromSource(source: AvatarSource | null, identities: IdentityRow[] = []): string | null {
if (!source || !source.provider) return null
const identity = identities.find(
(id) => id.provider === source.provider && id.providerUserId === source.providerUserId
)
if (identity?.avatar) {
return identity.avatar
}
switch (source.provider.toLowerCase()) {
case 'github':
if (identity?.providerUsername) {
return `https://github.com/${identity.providerUsername}.png?size=128`
}
if (source.providerUserId) {
return `https://avatars.githubusercontent.com/u/${source.providerUserId}?size=128`
}
return null
case 'google':
return null
case 'casdoor':
return null
default:
return null
}
}
export function getUserAvatarUrl(
user: AvatarUserState,
identities: IdentityRow[] = []
): string {
try {
const source = resolveAvatarSource(user, identities)
const sourceAvatar = source ? buildAvatarFromSource(source, identities) : null
if (sourceAvatar) return sourceAvatar
const gravatar = generateGravatarUrl(user.email)
if (gravatar) return gravatar
const seed = (user.name || user.username || user.id?.toString() || 'User')
return generateDiceBearUrl(seed)
} catch {
const seed = (user?.name || user?.username || user?.id?.toString() || 'User')
return generateDiceBearUrl(seed)
}
}