feat(admin): 优化用户权限分级·统一头像·数据分析面板修复
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 55s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s

- 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 等高角色
This commit is contained in:
2026-09-12 08:36:45 +08:00
parent 153ee41ef0
commit d09d12828d
10 changed files with 633 additions and 199 deletions
+102 -7
View File
@@ -85,18 +85,113 @@ function getRoleInfo(roleName) {
return ROLES[roleName] || null
}
// 检查用户是否可以管理其他用户角色
function canManageUserRole(currentUser, targetRole) {
if (!currentUser || !currentUser.role) return false
const ROLE_LEVELS = {
SUPER_ADMIN: 100,
ADMIN: 80,
MINISTER: 80,
VICE_MINISTER: 70,
SONG_ADMIN: 60,
STAFF: 60,
RESIDENT: 50,
TEACHER: 40,
USER: 10
}
export function getRoleLevel(roleName) {
if (!roleName) return 0
const upperRole = String(roleName).toUpperCase()
return ROLE_LEVELS[upperRole] ?? 0
}
export function isRoleHigherOrEqual(currentRole, targetRole) {
return getRoleLevel(currentRole) >= getRoleLevel(targetRole)
}
export function isRoleHigher(currentRole, targetRole) {
return getRoleLevel(currentRole) > getRoleLevel(targetRole)
}
function canResetPasswordFor(currentUser, targetUser) {
if (!currentUser || !currentUser.role || !targetUser) return false
const currentLevel = getRoleLevel(currentUser.role)
const targetLevel = getRoleLevel(targetUser.role)
// 只有超级管理员可以分配角色
if (currentUser.role === 'SUPER_ADMIN') {
// 超级管理员可以分配除自己以外的所有角色
return Object.keys(ROLES).includes(targetRole) && targetRole !== 'SUPER_ADMIN'
return true
}
if (currentUser.role === 'ADMIN' || currentUser.role === 'MINISTER') {
return targetLevel <= getRoleLevel('USER')
}
return false
}
function canChangeRoleOf(currentUser, targetUser, newRole) {
if (!currentUser || !currentUser.role || !targetUser) return false
const currentLevel = getRoleLevel(currentUser.role)
const targetLevel = getRoleLevel(targetUser.role)
const newRoleLevel = getRoleLevel(newRole)
if (String(currentUser.id) === String(targetUser.id)) {
return false
}
if (currentUser.role === 'SUPER_ADMIN') {
if (targetUser.id === 1) return false
return newRoleLevel <= currentLevel
}
if (currentLevel < targetLevel) {
return false
}
if (newRoleLevel >= currentLevel) {
return false
}
return true
}
function canDeleteUser(currentUser, targetUser) {
if (!currentUser || !currentUser.role || !targetUser) return false
if (String(currentUser.id) === String(targetUser.id)) {
return false
}
if (targetUser.id === 1) {
return false
}
const currentLevel = getRoleLevel(currentUser.role)
const targetLevel = getRoleLevel(targetUser.role)
return currentLevel > targetLevel
}
function canManageUserRole(currentUser, targetRole) {
if (!currentUser || !currentUser.role) return false
if (currentUser.role === 'SUPER_ADMIN') {
return Object.keys(ROLES).includes(targetRole) && targetRole !== 'SUPER_ADMIN'
}
const currentLevel = getRoleLevel(currentUser.role)
const targetLevel = getRoleLevel(targetRole)
return targetLevel < currentLevel
}
// 导出所有函数和常量
export { canAccessAdmin, canAccessPage, getUserPages, getRoleInfo, canManageUserRole }
export {
canAccessAdmin,
canAccessPage,
getUserPages,
getRoleInfo,
canManageUserRole,
canResetPasswordFor,
canChangeRoleOf,
canDeleteUser
}
+77
View File
@@ -1,6 +1,10 @@
interface AvatarUserState {
avatarProvider?: string | null
avatarProviderUserId?: string | null
email?: string | null
username?: string | null
name?: string | null
id?: string | number | null
}
interface IdentityRow {
@@ -8,6 +12,7 @@ interface IdentityRow {
providerUserId: string
avatar?: string | null
createdAt: Date | string
providerUsername?: string | null
}
export interface AvatarSource {
@@ -43,3 +48,75 @@ export function resolveAvatarSource(
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)
}
}