- 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 等高角色
123 lines
3.4 KiB
TypeScript
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)
|
|
}
|
|
}
|