Files
BYW-VoiceHub/patches/0043-feat-admin.patch
T

1410 lines
54 KiB
Diff
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
From d09d12828ddf22c01dc1be89c60dcdf63ff5f7f9 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 08:36:45 +0800
Subject: [PATCH 43/58] =?UTF-8?q?feat(admin):=20=E4=BC=98=E5=8C=96?=
=?UTF-8?q?=E7=94=A8=E6=88=B7=E6=9D=83=E9=99=90=E5=88=86=E7=BA=A7=C2=B7?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=A4=B4=E5=83=8F=C2=B7=E6=95=B0=E6=8D=AE?=
=?UTF-8?q?=E5=88=86=E6=9E=90=E9=9D=A2=E6=9D=BF=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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 等高角色
---
app/components/Admin/DataAnalysisPanel.vue | 121 ++++++++-----
app/components/Admin/UserManager.vue | 133 +++++++++++++--
server/api/admin/stats/active-users.get.ts | 47 ++---
server/api/admin/stats/realtime.get.ts | 160 +++++++++++++-----
server/api/admin/users/[id].put.ts | 48 +++---
.../admin/users/[id]/reset-password.post.ts | 62 ++++++-
server/api/admin/users/index.get.ts | 40 +++--
server/api/admin/users/index.post.ts | 31 ++--
server/utils/permissions.js | 105 +++++++++++-
server/utils/user-avatar.ts | 77 +++++++++
10 files changed, 629 insertions(+), 195 deletions(-)
diff --git a/app/components/Admin/DataAnalysisPanel.vue b/app/components/Admin/DataAnalysisPanel.vue
index d4d91c8..0fa7fb6 100644
--- a/app/components/Admin/DataAnalysisPanel.vue
+++ b/app/components/Admin/DataAnalysisPanel.vue
@@ -395,43 +395,53 @@
</div>
<div
v-else-if="activeUsers.length > 0"
- class="flex-1 space-y-3 overflow-y-auto custom-scrollbar pr-2"
+ class="relative max-h-[400px] overflow-hidden"
>
- <div
- v-for="(user, i) in activeUsers.slice(0, 5)"
- :key="i"
- class="relative p-5 bg-zinc-950/30 border border-zinc-800/50 rounded-3xl overflow-hidden group"
- >
- <div class="flex items-center gap-4 relative z-10">
- <div
- class="w-12 h-12 rounded-2xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-zinc-200 transition-colors"
- >
- {{ user.name.charAt(0) }}
- </div>
- <div class="flex-1">
- <h4 class="text-sm font-bold text-zinc-100">{{ user.name }}</h4>
- <p class="text-xs text-zinc-600 font-medium mt-1">
- {{ user.contributions }}首投稿 · {{ user.likes }}次点赞
- </p>
- </div>
- <div class="text-right">
- <span
- class="text-xl font-black text-zinc-300 group-hover:text-purple-400 transition-colors"
- >{{ user.activityScore }}</span
+ <div class="space-y-3 overflow-y-auto custom-scrollbar pr-2 max-h-[400px] pb-6">
+ <div
+ v-for="(user, i) in activeUsers.slice(0, 5)"
+ :key="i"
+ class="relative p-5 bg-zinc-950/30 border border-zinc-800/50 rounded-3xl overflow-hidden group"
+ >
+ <div class="flex items-center gap-4 relative z-10">
+ <img
+ v-if="user.avatarUrl && !failedAvatars[user.id]"
+ :src="user.avatarUrl"
+ :alt="user.name"
+ class="w-12 h-12 rounded-2xl object-cover border border-zinc-700/50 shrink-0"
+ @error="handleAvatarError($event, user)"
>
- <p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
- 活跃度
- </p>
+ <div
+ v-else
+ class="w-12 h-12 rounded-2xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-zinc-200 transition-colors shrink-0"
+ >
+ {{ user.name.charAt(0) }}
+ </div>
+ <div class="flex-1 min-w-0">
+ <h4 class="text-sm font-bold text-zinc-100 truncate">{{ user.name }}</h4>
+ <p class="text-xs text-zinc-600 font-medium mt-1 truncate">
+ {{ user.contributions }}首投稿 · {{ user.likes }}次点赞
+ </p>
+ </div>
+ <div class="text-right shrink-0">
+ <span
+ class="text-xl font-black text-zinc-300 group-hover:text-purple-400 transition-colors"
+ >{{ user.activityScore }}</span
+ >
+ <p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
+ 活跃度
+ </p>
+ </div>
</div>
+ <div
+ class="absolute bottom-0 left-0 h-1 bg-purple-500/20 group-hover:bg-purple-500/40 transition-all"
+ :style="{
+ width: `${(user.activityScore / Math.max(...activeUsers.map((u) => u.activityScore), 1)) * 100}%`
+ }"
+ />
</div>
- <!-- 进度条指示器 -->
- <div
- class="absolute bottom-0 left-0 h-1 bg-purple-500/20 group-hover:bg-purple-500/40 transition-all"
- :style="{
- width: `${(user.activityScore / Math.max(...activeUsers.map((u) => u.activityScore), 1)) * 100}%`
- }"
- />
</div>
+ <div class="absolute bottom-0 left-0 right-0 h-16 pointer-events-none bg-gradient-to-t from-zinc-900/90 via-zinc-900/40 to-transparent rounded-b-[2rem]" />
</div>
<div v-else class="flex-1 flex flex-col items-center justify-center text-zinc-700">
<Users :size="32" class="opacity-20 mb-2" />
@@ -537,14 +547,14 @@
@mouseleave="handleTooltipMouseLeave"
>
<div
- class="bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden min-w-[320px] backdrop-blur-xl bg-opacity-90 animate-in fade-in zoom-in duration-200"
+ class="bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden min-w-[320px] max-w-[380px] backdrop-blur-xl bg-opacity-90 animate-in fade-in zoom-in duration-200"
>
<div
- class="p-6 border-b border-zinc-800/50 bg-gradient-to-br from-blue-500/10 to-transparent"
+ class="p-6 border-b border-zinc-800/50 bg-gradient-to-br from-blue-500/10 to-transparent max-h-[500px] overflow-y-auto custom-scrollbar"
>
<div class="flex items-center justify-between mb-4">
- <h4 class="text-sm font-black text-white uppercase tracking-widest">活跃用户详情</h4>
- <div class="flex items-center gap-2 px-2 py-1 bg-blue-500/20 rounded-full">
+ <h4 class="text-sm font-black text-white uppercase tracking-widest truncate max-h-[20px] overflow-hidden">活跃用户详情</h4>
+ <div class="flex items-center gap-2 px-2 py-1 bg-blue-500/20 rounded-full shrink-0">
<div class="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
<span class="text-[10px] font-black text-blue-400"
>{{ realtimeStats.activeUsers }} 在线</span
@@ -554,44 +564,52 @@
<div
v-if="realtimeStats.activeUsersList && realtimeStats.activeUsersList.length > 0"
- class="space-y-4"
+ class="space-y-3 max-h-[350px] overflow-y-auto custom-scrollbar pr-1"
>
<div
v-for="user in realtimeStats.activeUsersList.slice(0, 5)"
:key="user.id"
class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl group hover:border-blue-500/30 transition-all"
>
+ <img
+ v-if="user.avatarUrl && !failedAvatars[user.id]"
+ :src="user.avatarUrl"
+ :alt="user.name"
+ class="w-10 h-10 rounded-xl object-cover border border-zinc-700/50 shrink-0"
+ @error="handleAvatarError($event, user)"
+ >
<div
- class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-blue-400 transition-colors"
+ v-else
+ class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-blue-400 transition-colors shrink-0"
>
{{ user.name.charAt(0) }}
</div>
<div class="flex-1 min-w-0">
- <div class="text-xs font-bold text-zinc-200 truncate">{{ user.name }}</div>
- <div class="text-[10px] text-zinc-500 font-medium truncate">
+ <div class="text-xs font-bold text-zinc-200 truncate max-h-[16px] overflow-hidden">{{ user.name }}</div>
+ <div class="text-[10px] text-zinc-500 font-medium truncate max-h-[14px] overflow-hidden">
@{{ user.username }}
</div>
</div>
- <div class="text-[10px] font-black text-zinc-600 bg-zinc-900 px-2 py-1 rounded-lg">
+ <div class="text-[10px] font-black text-zinc-600 bg-zinc-900 px-2 py-1 rounded-lg shrink-0">
{{ user.lastActive }}
</div>
</div>
<div v-if="realtimeStats.activeUsersList.length > 5" class="text-center py-2">
- <span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
+ <span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest max-h-[14px] overflow-hidden block"
>及其他 {{ realtimeStats.activeUsersList.length - 5 }} 位用户</span
>
</div>
</div>
<div v-else class="py-10 flex flex-col items-center justify-center text-zinc-600">
<Users :size="32" class="opacity-20 mb-3" />
- <p class="text-xs font-black uppercase tracking-widest">暂无在线用户</p>
+ <p class="text-xs font-black uppercase tracking-widest truncate max-h-[16px] overflow-hidden">暂无在线用户</p>
</div>
</div>
<div class="px-6 py-4 bg-zinc-950/50 flex items-center justify-between">
- <span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
+ <span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest truncate max-h-[14px] overflow-hidden"
>实时数据同步中</span
>
- <Activity :size="12" class="text-blue-500 animate-pulse" />
+ <Activity :size="12" class="text-blue-500 animate-pulse shrink-0" />
</div>
</div>
</div>
@@ -600,7 +618,7 @@
</template>
<script setup>
-import { onMounted, ref, computed } from 'vue'
+import { onMounted, ref, computed, reactive } from 'vue'
import {
TrendingUp,
Users,
@@ -727,6 +745,17 @@ const tooltip = ref({
}
})
+// 头像加载失败状态管理
+const failedAvatars = reactive({})
+const handleAvatarError = (event, user) => {
+ if (user && user.id) {
+ failedAvatars[user.id] = true
+ }
+ if (event && event.target) {
+ event.target.style.display = 'none'
+ }
+}
+
// 鼠标进入事件处理
const handleMouseEnter = (event) => {
const rect = event.target.getBoundingClientRect()
diff --git a/app/components/Admin/UserManager.vue b/app/components/Admin/UserManager.vue
index 7252dc2..7af836d 100644
--- a/app/components/Admin/UserManager.vue
+++ b/app/components/Admin/UserManager.vue
@@ -221,9 +221,9 @@
class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-all action-buttons"
>
<button
- :disabled="isSelf(user)"
+ :disabled="isSelf(user) || !canManageTargetUser(user)"
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-blue-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
- title="编辑用户"
+ :title="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
@click="editUser(user)"
>
<Edit2 :size="13" />
@@ -236,17 +236,17 @@
<Music :size="13" />
</button>
<button
- :disabled="isSelf(user)"
+ :disabled="isSelf(user) || !canResetPasswordFor(user)"
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-amber-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
- title="重置密码"
+ :title="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
@click="resetPassword(user)"
>
<Lock :size="13" />
</button>
<button
- :disabled="isSelf(user)"
+ :disabled="isSelf(user) || !canManageTargetUser(user)"
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-red-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
- title="删除用户"
+ :title="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
@click="confirmDeleteUser(user)"
>
<Trash2 :size="13" />
@@ -363,8 +363,9 @@
<div class="flex gap-2 action-buttons">
<button
- :disabled="isSelf(user)"
+ :disabled="isSelf(user) || !canManageTargetUser(user)"
class="flex-1 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-blue-600 active:text-white transition-colors disabled:opacity-20 action-btn"
+ :title="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
@click="editUser(user)"
>
<Edit2 :size="12" /> 编辑
@@ -376,15 +377,17 @@
<Music :size="12" /> 记录
</button>
<button
- :disabled="isSelf(user)"
+ :disabled="isSelf(user) || !canResetPasswordFor(user)"
class="flex-1 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-amber-600 active:text-white transition-colors disabled:opacity-20 action-btn"
+ :title="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
@click="resetPassword(user)"
>
<Lock :size="12" /> 重置
</button>
<button
- :disabled="isSelf(user)"
+ :disabled="isSelf(user) || !canManageTargetUser(user)"
class="px-3 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 active:bg-red-600 active:text-white transition-colors disabled:opacity-20 action-btn"
+ :title="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
@click="confirmDeleteUser(user)"
>
<Trash2 :size="14" />
@@ -1493,6 +1496,25 @@ const passwordForm = ref({
const auth = useAuth()
const permissions = usePermissions()
+// 前端侧角色级别映射(与 server/utils/permissions.js 的 ROLE_LEVELS 保持一致)
+const ROLE_LEVELS = {
+ SUPER_ADMIN: 100,
+ ADMIN: 80,
+ MINISTER: 80,
+ VICE_MINISTER: 70,
+ SONG_ADMIN: 60,
+ STAFF: 60,
+ RESIDENT: 50,
+ TEACHER: 40,
+ USER: 10
+}
+
+const getRoleLevel = (roleName) => {
+ if (!roleName) return 0
+ const name = String(roleName).toUpperCase()
+ return ROLE_LEVELS[name] ?? 0
+}
+
// 判断是否为当前登录用户
const isSelf = (user) => {
return auth.user.value?.id === user?.id
@@ -1503,14 +1525,53 @@ const isSuperAdmin = computed(() => {
return auth.user.value?.role === 'SUPER_ADMIN'
})
+const currentUserRoleLevel = computed(() => getRoleLevel(auth.user.value?.role))
+
+// 是否为普通管理员(ADMIN/MINISTER 级别,非 SUPER)
+const isAdminOnly = computed(() => {
+ return !isSuperAdmin.value && currentUserRoleLevel.value >= 80
+})
+
+// 操作者能否对目标用户执行管理操作(目标级别必须严格低于操作者,且目标不是 ID=1)
+const canManageTargetUser = (targetUser) => {
+ if (!targetUser) return false
+ if (isSelf(targetUser)) return false
+ if (String(targetUser.id) === '1') return false
+ if (isSuperAdmin.value) return true
+ const targetLevel = getRoleLevel(targetUser.role)
+ return currentUserRoleLevel.value > targetLevel
+}
+
+// 操作者能否重置目标用户的密码(ADMIN 仅可重置 USER 及以下)
+const canResetPasswordFor = (targetUser) => {
+ if (!canManageTargetUser(targetUser)) return false
+ if (isSuperAdmin.value) return true
+ // 普通 ADMIN 只能重置级别 <= USER(10) 的用户
+ const targetLevel = getRoleLevel(targetUser.role)
+ return targetLevel <= ROLE_LEVELS.USER
+}
+
+// 操作者能否将角色改为指定 newRole
+const canAssignRole = (newRole) => {
+ if (!newRole) return false
+ if (isSuperAdmin.value) {
+ return String(newRole).toUpperCase() !== 'SUPER_ADMIN'
+ }
+ const targetLevel = getRoleLevel(newRole)
+ return currentUserRoleLevel.value > targetLevel
+}
+
const availableRoles = computed(() => {
if (isSuperAdmin.value) {
// 超级管理员可以分配除自己以外的所有角色
return allRoles.value.filter((role) => role.name !== 'SUPER_ADMIN')
- } else {
- // 其他角色不能分配角色,返回空数组
- return []
}
+ // 普通 ADMIN/MINISTER 等只能分配级别严格低于自己的角色(SUPER_ADMIN 已在上层排除)
+ return allRoles.value.filter(
+ (role) =>
+ role.name !== 'SUPER_ADMIN' &&
+ getRoleLevel(role.name) < currentUserRoleLevel.value
+ )
})
// 由于使用服务器端分页,这些计算属性简化了
@@ -1594,13 +1655,18 @@ const getStatusDisplayName = (status) => {
}
const editUser = (user) => {
- // 禁止编辑自身
if (isSelf(user)) {
if (window.$showNotification) {
window.$showNotification('禁止在用户管理中修改自己的账户', 'warning')
}
return
}
+ if (!canManageTargetUser(user)) {
+ if (window.$showNotification) {
+ window.$showNotification('权限不足:无法编辑该用户', 'warning')
+ }
+ return
+ }
editingUser.value = user
userForm.value = {
name: user.name,
@@ -1614,13 +1680,18 @@ const editUser = (user) => {
}
const resetPassword = (user) => {
- // 禁止重置自身密码
if (isSelf(user)) {
if (window.$showNotification) {
window.$showNotification('禁止在用户管理中重置自己的密码', 'warning')
}
return
}
+ if (!canResetPasswordFor(user)) {
+ if (window.$showNotification) {
+ window.$showNotification('权限不足:无法重置该用户的密码', 'warning')
+ }
+ return
+ }
resetPasswordUser.value = user
passwordForm.value = {
password: '',
@@ -1629,13 +1700,18 @@ const resetPassword = (user) => {
}
const confirmDeleteUser = (user) => {
- // 禁止删除自身
if (isSelf(user)) {
if (window.$showNotification) {
window.$showNotification('不能删除自己的账户', 'warning')
}
return
}
+ if (!canManageTargetUser(user)) {
+ if (window.$showNotification) {
+ window.$showNotification('权限不足:无法删除该用户', 'warning')
+ }
+ return
+ }
deletingUser.value = user
showDeleteModal.value = true
}
@@ -1687,6 +1763,20 @@ const closeDeleteModal = () => {
const confirmDelete = async () => {
if (!deletingUser.value) return
+ if (isSelf(deletingUser.value)) {
+ if (window.$showNotification) {
+ window.$showNotification('不能删除自己的账户', 'warning')
+ }
+ closeDeleteModal()
+ return
+ }
+ if (!canManageTargetUser(deletingUser.value)) {
+ if (window.$showNotification) {
+ window.$showNotification('权限不足:无法删除该用户', 'warning')
+ }
+ closeDeleteModal()
+ return
+ }
deleting.value = true
@@ -1737,11 +1827,22 @@ const closeResetPassword = () => {
}
const saveUser = async () => {
- // 保护:禁止保存针对自身的更改
if (editingUser.value && isSelf(editingUser.value)) {
formError.value = '禁止在用户管理中修改自己的账户'
return
}
+ if (editingUser.value && !canManageTargetUser(editingUser.value)) {
+ formError.value = '权限不足:无法编辑该用户'
+ return
+ }
+ if (userForm.value.role && !canAssignRole(userForm.value.role)) {
+ formError.value = `权限不足:无法分配角色 ${userForm.value.role}`
+ return
+ }
+ if (userForm.value.password && editingUser.value && !canResetPasswordFor(editingUser.value)) {
+ formError.value = '权限不足:无法重置该用户的密码'
+ return
+ }
if (!userForm.value.name || !userForm.value.username) {
formError.value = '请填写必要信息'
return
diff --git a/server/api/admin/stats/active-users.get.ts b/server/api/admin/stats/active-users.get.ts
index ac05201..34756ca 100644
--- a/server/api/admin/stats/active-users.get.ts
+++ b/server/api/admin/stats/active-users.get.ts
@@ -1,10 +1,10 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
-import { songs, users, votes } from '~/drizzle/schema'
+import { songs, users, votes, userIdentities } from '~/drizzle/schema'
import { CacheService } from '../../../services/cacheService'
+import { getUserAvatarUrl } from '../../../utils/user-avatar'
export default defineEventHandler(async (event) => {
- // 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
@@ -18,7 +18,6 @@ export default defineEventHandler(async (event) => {
const limit = parseInt(query.limit as string) || 20
try {
- // 尝试从缓存获取数据
const cacheService = CacheService.getInstance()
const cachedStats = await cacheService.getActiveUsersStats(semester, limit)
if (cachedStats) {
@@ -26,18 +25,24 @@ export default defineEventHandler(async (event) => {
return cachedStats
}
- // 构建查询条件 - 如果没有指定学期或学期为'all',查询所有用户
let songWhere = {}
if (semester && semester !== 'all') {
songWhere = { semester: semester }
}
- // 获取活跃用户数据 - 有投稿歌曲的用户
const allUsers = await db.select().from(users)
const allSongs = await db.select().from(songs)
const allVotes = await db.select().from(votes)
+ const allIdentities = await db.select().from(userIdentities)
+
+ const identitiesMap = new Map<number, typeof allIdentities>()
+ allIdentities.forEach((identity) => {
+ if (!identitiesMap.has(identity.userId)) {
+ identitiesMap.set(identity.userId, [])
+ }
+ identitiesMap.get(identity.userId)!.push(identity)
+ })
- // 过滤有投稿歌曲的用户
const activeUsers = allUsers
.filter((user) => {
const userSongs = allSongs.filter((song) => {
@@ -65,44 +70,48 @@ export default defineEventHandler(async (event) => {
}
})
- // 如果没有活跃用户,返回空数组
if (activeUsers.length === 0) {
const emptyResult = []
- // 缓存空结果
await cacheService.setActiveUsersStats(semester, limit, emptyResult)
console.log('[Cache] 活跃用户统计数据已缓存(空结果)')
return emptyResult
}
- // 计算每个用户的活跃度数据
const userStats = activeUsers.map((user) => {
const contributions = user.songs.length
const likes = user.songs.reduce((total, song) => total + song.votes.length, 0)
-
- // 活跃度计算:投稿数 * 2 + 点赞数 * 0.5
const activityScore = Math.round(contributions * 2 + likes * 0.5)
+ const userIdentitiesData = identitiesMap.get(user.id) || []
+ const avatarUrl = getUserAvatarUrl(
+ {
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ email: user.email,
+ avatarProvider: user.avatarProvider,
+ avatarProviderUserId: user.avatarProviderUserId
+ },
+ userIdentitiesData
+ )
- const userStat = {
+ return {
id: user.id,
- name: user.name || user.username, // 优先显示真实姓名,如果没有则显示登录名
+ name: user.name || user.username,
contributions,
likes,
- activityScore
+ activityScore,
+ avatarUrl
}
-
- return userStat
})
- // 按活跃度分数降序排列并限制数量
const sortedUsers = userStats.sort((a, b) => b.activityScore - a.activityScore).slice(0, limit)
- // 缓存结果
await cacheService.setActiveUsersStats(semester, limit, sortedUsers)
console.log('[Cache] 活跃用户统计数据已缓存')
return sortedUsers
} catch (error) {
- // 返回空数组而不是抛出错误,避免前端显示错误
+ console.error('[Stats] 获取活跃用户失败:', error)
return []
}
})
diff --git a/server/api/admin/stats/realtime.get.ts b/server/api/admin/stats/realtime.get.ts
index 030e2a8..86ed510 100644
--- a/server/api/admin/stats/realtime.get.ts
+++ b/server/api/admin/stats/realtime.get.ts
@@ -1,12 +1,24 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
import { CacheService } from '../../../services/cacheService'
-import { songs, users, votes } from '~/drizzle/schema'
-import { count, eq, gte } from 'drizzle-orm'
+import { songs, users, votes, userIdentities } from '~/drizzle/schema'
+import { count, eq, gte, inArray } from 'drizzle-orm'
import { getBeijingHour, getBeijingStartOfDay } from '~/utils/timeUtils'
+import { getUserAvatarUrl } from '../../../utils/user-avatar'
+
+function formatRelativeTime(date: Date | null | undefined): string {
+ if (!date) return '未知'
+ const now = new Date()
+ const diff = now.getTime() - new Date(date).getTime()
+ const minutes = Math.floor(diff / 60000)
+ if (minutes < 1) return '刚刚'
+ if (minutes < 60) return `${minutes}分钟前`
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}小时前`
+ return `${Math.floor(hours / 24)}天前`
+}
export default defineEventHandler(async (event) => {
- // 检查认证和权限
const user = event.context.user
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({
@@ -16,97 +28,172 @@ export default defineEventHandler(async (event) => {
}
try {
- // 尝试从缓存获取数据
const cacheService = CacheService.getInstance()
const cachedStats = await cacheService.getRealtimeStats()
if (cachedStats) {
console.log('[Cache] 实时统计数据缓存命中')
return cachedStats
}
- // 获取当前时间相关的日期
const now = new Date()
- const today = getBeijingStartOfDay() // 使用北京时间的一天开始
+ const today = getBeijingStartOfDay()
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000)
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000)
- // 并行获取实时统计数据
const [activeUsersData, todayRequests, popularGenres, peakHours] = await Promise.all([
- // 活跃用户数和用户列表 (最近1小时点歌、登录或点赞的用户)
(async () => {
try {
- // 获取最近1小时内点歌的用户
const recentSongUsers = await db
.select({
requesterId: songs.requesterId,
requesterName: users.name,
requesterUsername: users.username,
- requesterId2: users.id
+ requesterId2: users.id,
+ requesterLastActive: songs.createdAt,
+ requesterAvatarProvider: users.avatarProvider,
+ requesterAvatarProviderUserId: users.avatarProviderUserId,
+ requesterEmail: users.email
})
.from(songs)
.innerJoin(users, eq(songs.requesterId, users.id))
.where(gte(songs.createdAt, oneHourAgo))
- .groupBy(songs.requesterId, users.id, users.name, users.username)
+ .groupBy(songs.requesterId, users.id, users.name, users.username, users.lastLogin, users.avatarProvider, users.avatarProviderUserId, users.email, songs.createdAt)
- // 获取最近1小时内登录的用户
const recentLoginUsers = await db
.select({
id: users.id,
username: users.username,
- name: users.name
+ name: users.name,
+ lastLogin: users.lastLogin,
+ avatarProvider: users.avatarProvider,
+ avatarProviderUserId: users.avatarProviderUserId,
+ email: users.email
})
.from(users)
.where(gte(users.lastLogin, oneHourAgo))
- // 获取最近1小时内点赞过歌曲的用户
const recentVoteUsers = await db
.select({
userId: votes.userId,
userName: users.name,
userUsername: users.username,
- userId2: users.id
+ userId2: users.id,
+ voteCreatedAt: votes.createdAt,
+ userAvatarProvider: users.avatarProvider,
+ userAvatarProviderUserId: users.avatarProviderUserId,
+ userEmail: users.email
})
.from(votes)
.innerJoin(users, eq(votes.userId, users.id))
.where(gte(votes.createdAt, oneHourAgo))
- .groupBy(votes.userId, users.id, users.name, users.username)
+ .groupBy(votes.userId, users.id, users.name, users.username, users.avatarProvider, users.avatarProviderUserId, users.email, votes.createdAt)
- // 合并并去重用户列表
- const userMap = new Map()
+ const userMap = new Map<number, any>()
- // 添加点歌用户
recentSongUsers.forEach((song) => {
- userMap.set(song.requesterId2, {
- id: song.requesterId2,
+ const id = song.requesterId2
+ const existing = userMap.get(id)
+ const lastActive = existing?.lastActive
+ ? new Date(existing.lastActive) > new Date(song.requesterLastActive)
+ ? existing.lastActive
+ : song.requesterLastActive
+ : song.requesterLastActive
+ userMap.set(id, {
+ id,
username: song.requesterUsername,
- name: song.requesterName
+ name: song.requesterName,
+ email: song.requesterEmail,
+ avatarProvider: song.requesterAvatarProvider,
+ avatarProviderUserId: song.requesterAvatarProviderUserId,
+ lastActive
})
})
- // 添加登录用户
- recentLoginUsers.forEach((user) => {
- userMap.set(user.id, {
- id: user.id,
- username: user.username,
- name: user.name
+ recentLoginUsers.forEach((u) => {
+ const existing = userMap.get(u.id)
+ const lastActive = existing?.lastActive
+ ? new Date(existing.lastActive) > new Date(u.lastLogin)
+ ? existing.lastActive
+ : u.lastLogin
+ : u.lastLogin
+ userMap.set(u.id, {
+ id: u.id,
+ username: u.username,
+ name: u.name,
+ email: u.email,
+ avatarProvider: u.avatarProvider,
+ avatarProviderUserId: u.avatarProviderUserId,
+ lastActive
})
})
- // 添加点赞用户
recentVoteUsers.forEach((vote) => {
- userMap.set(vote.userId2, {
- id: vote.userId2,
+ const id = vote.userId2
+ const existing = userMap.get(id)
+ const lastActive = existing?.lastActive
+ ? new Date(existing.lastActive) > new Date(vote.voteCreatedAt)
+ ? existing.lastActive
+ : vote.voteCreatedAt
+ : vote.voteCreatedAt
+ userMap.set(id, {
+ id,
username: vote.userUsername,
- name: vote.userName
+ name: vote.userName,
+ email: vote.userEmail,
+ avatarProvider: vote.userAvatarProvider,
+ avatarProviderUserId: vote.userAvatarProviderUserId,
+ lastActive
})
})
- const activeUsersList = Array.from(userMap.values())
+ const userIds = Array.from(userMap.keys())
+ let identitiesData: any[] = []
+ if (userIds.length > 0) {
+ try {
+ identitiesData = await db
+ .select()
+ .from(userIdentities)
+ .where(inArray(userIdentities.userId, userIds))
+ } catch (e) {
+ identitiesData = []
+ }
+ }
+
+ const identitiesMap = new Map<number, any[]>()
+ identitiesData.forEach((identity) => {
+ if (!identitiesMap.has(identity.userId)) {
+ identitiesMap.set(identity.userId, [])
+ }
+ identitiesMap.get(identity.userId)!.push(identity)
+ })
+
+ const activeUsersList = Array.from(userMap.values()).map((u) => {
+ const userIdentitiesData = identitiesMap.get(u.id) || []
+ const avatarUrl = getUserAvatarUrl(
+ {
+ id: u.id,
+ name: u.name,
+ username: u.username,
+ email: u.email,
+ avatarProvider: u.avatarProvider,
+ avatarProviderUserId: u.avatarProviderUserId
+ },
+ userIdentitiesData
+ )
+ return {
+ id: u.id,
+ username: u.username,
+ name: u.name,
+ avatarUrl,
+ lastActive: formatRelativeTime(u.lastActive)
+ }
+ })
return {
count: activeUsersList.length,
users: activeUsersList
}
} catch (error) {
+ console.error('[Realtime] 获取活跃用户失败:', error)
return {
count: 0,
users: []
@@ -114,7 +201,6 @@ export default defineEventHandler(async (event) => {
}
})(),
- // 今日点歌数
(async () => {
try {
const todayCountResult = await db
@@ -127,12 +213,10 @@ export default defineEventHandler(async (event) => {
}
})(),
- // 热门流派 (Song模型中没有genre字段,返回空数组)
(async () => {
return []
})(),
- // 高峰时段 (最近7天的小时统计)
(async () => {
try {
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
@@ -143,7 +227,6 @@ export default defineEventHandler(async (event) => {
.from(songs)
.where(gte(songs.createdAt, sevenDaysAgo))
- // 按小时统计
const hourCount = songsData.reduce(
(acc, song) => {
const hour = getBeijingHour(song.createdAt)
@@ -153,7 +236,6 @@ export default defineEventHandler(async (event) => {
{} as Record<number, number>
)
- // 返回前3个高峰时段
return Object.entries(hourCount)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
@@ -177,12 +259,12 @@ export default defineEventHandler(async (event) => {
timestamp: now.toISOString()
}
- // 缓存结果
await cacheService.setRealtimeStats(result)
console.log('[Cache] 实时统计数据已缓存')
return result
} catch (error) {
+ console.error('[Realtime] 获取失败:', error)
throw createError({
statusCode: 500,
message: '获取实时统计数据失败'
diff --git a/server/api/admin/users/[id].put.ts b/server/api/admin/users/[id].put.ts
index a9cc874..eeb5ff3 100644
--- a/server/api/admin/users/[id].put.ts
+++ b/server/api/admin/users/[id].put.ts
@@ -3,6 +3,7 @@ import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { updateUserPassword } from '~~/server/services/userService'
+import { getRoleLevel, canChangeRoleOf, canResetPasswordFor } from '~~/server/utils/permissions.js'
export default defineEventHandler(async (event) => {
try {
@@ -51,6 +52,8 @@ export default defineEventHandler(async (event) => {
}
const targetUser = existingUser[0]
+ const currentLevel = getRoleLevel(user.role)
+ const targetLevel = getRoleLevel(targetUser.role)
// 1. 禁止修改系统初始超级管理员 (ID: 1)
if (targetUser.id === 1) {
@@ -61,7 +64,6 @@ export default defineEventHandler(async (event) => {
}
// 2. 禁止对自身进行任何用户管理操作(包括角色、资料、状态、密码等)
- // 使用 String 转换确保 ID 比较的准确性
if (String(userId) === String(user.id)) {
throw createError({
statusCode: 400,
@@ -69,12 +71,11 @@ export default defineEventHandler(async (event) => {
})
}
- // 3. 越级修改保护
- // 如果目标用户是 SUPER_ADMIN,操作者必须是 SUPER_ADMIN
- if (targetUser.role === 'SUPER_ADMIN' && user.role !== 'SUPER_ADMIN') {
+ // 3. 非超级管理员必须严格高于目标角色级别
+ if (user.role !== 'SUPER_ADMIN' && currentLevel <= targetLevel) {
throw createError({
statusCode: 403,
- statusMessage: '权限不足:普通管理员无法修改超级管理员信息'
+ statusMessage: `权限不足:${user.role} 无法操作同级或更高角色的用户`
})
}
@@ -94,31 +95,26 @@ export default defineEventHandler(async (event) => {
}
}
- // 角色权限控制
- let validRole = 'USER'
- if (role && ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(role)) {
- // 超级管理员可以设置任何角色
- if (user.role === 'SUPER_ADMIN') {
- validRole = role
- }
- // 管理员只能设置管理员以下的角色(USER, SONG_ADMIN)
- else if (user.role === 'ADMIN') {
- if (['USER', 'TEACHER', 'SONG_ADMIN'].includes(role)) {
- validRole = role
- } else {
- throw createError({
- statusCode: 403,
- statusMessage: '管理员只能设置用户、教师和歌曲管理员角色'
- })
- }
- }
- // 其他角色不能设置角色
- else {
+ // 角色权限控制(统一使用 canChangeRoleOf 语义化检查)
+ let validRole = targetUser.role
+ if (role && role !== targetUser.role) {
+ if (!canChangeRoleOf(user, targetUser, role)) {
throw createError({
statusCode: 403,
- statusMessage: '没有权限设置用户角色'
+ statusMessage: user.role === 'SUPER_ADMIN'
+ ? '超级管理员不能将目标用户提升为 SUPER_ADMIN'
+ : `权限不足:${user.role} 无法将用户角色更改为 ${role}`
})
}
+ validRole = role
+ }
+
+ // 如果请求中包含密码更新,校验密码重置权限
+ if (password && !canResetPasswordFor(user, targetUser)) {
+ throw createError({
+ statusCode: 403,
+ statusMessage: `权限不足:${user.role} 无法重置目标用户的密码`
+ })
}
// 验证status字段的有效性
diff --git a/server/api/admin/users/[id]/reset-password.post.ts b/server/api/admin/users/[id]/reset-password.post.ts
index 18e9cc7..968655f 100644
--- a/server/api/admin/users/[id]/reset-password.post.ts
+++ b/server/api/admin/users/[id]/reset-password.post.ts
@@ -1,16 +1,18 @@
import { updateUserPassword } from '~~/server/services/userService'
+import { db } from '~/drizzle/db'
+import { users } from '~/drizzle/schema'
+import { eq } from 'drizzle-orm'
+import { canResetPasswordFor, getRoleLevel } from '../../../utils/permissions.js'
export default defineEventHandler(async (event) => {
- // 检查认证和权限
- const user = event.context.user
- if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
+ const currentUser = event.context.user
+ if (!currentUser || !['ADMIN', 'SUPER_ADMIN'].includes(currentUser.role)) {
throw createError({
statusCode: 403,
message: '没有权限访问'
})
}
- // 安全获取ID参数
const params = event.context.params || {}
const id = parseInt(params.id || '')
@@ -31,15 +33,54 @@ export default defineEventHandler(async (event) => {
}
try {
- // 禁止对自身进行密码重置(通过用户管理界面)
- if (id === user.id) {
+ if (id === currentUser.id) {
throw createError({
statusCode: 400,
message: '禁止在用户管理中重置自己的密码'
})
}
-
- // 使用统一服务重置密码 (forceReset = true)
+
+ const targetUserResult = await db
+ .select({ id: users.id, role: users.role, username: users.username })
+ .from(users)
+ .where(eq(users.id, id))
+ .limit(1)
+
+ if (targetUserResult.length === 0) {
+ throw createError({
+ statusCode: 404,
+ message: '用户不存在'
+ })
+ }
+
+ const targetUser = targetUserResult[0]
+
+ if (targetUser.id === 1) {
+ throw createError({
+ statusCode: 403,
+ message: '无法重置系统初始超级管理员的密码'
+ })
+ }
+
+ const currentLevel = getRoleLevel(currentUser.role)
+ const targetLevel = getRoleLevel(targetUser.role)
+
+ if (currentUser.role !== 'SUPER_ADMIN') {
+ if (currentLevel <= targetLevel) {
+ throw createError({
+ statusCode: 403,
+ message: '权限不足:不能重置角色级别大于等于自己的用户密码'
+ })
+ }
+ }
+
+ if (!canResetPasswordFor(currentUser, targetUser)) {
+ throw createError({
+ statusCode: 403,
+ message: '权限不足:当前角色无法重置该用户的密码'
+ })
+ }
+
await updateUserPassword(id, body.newPassword, true)
return {
@@ -47,7 +88,10 @@ export default defineEventHandler(async (event) => {
message: '密码重置成功'
}
} catch (error) {
- console.error('重置密码失败:', error)
+ console.error('[ResetPassword] 失败:', error)
+ if (error.statusCode) {
+ throw error
+ }
throw createError({
statusCode: 500,
message: '重置密码失败'
diff --git a/server/api/admin/users/index.get.ts b/server/api/admin/users/index.get.ts
index 950aa84..437b652 100644
--- a/server/api/admin/users/index.get.ts
+++ b/server/api/admin/users/index.get.ts
@@ -96,26 +96,36 @@ export default defineEventHandler(async (event) => {
meowNickname: true,
meowBoundAt: true,
createdAt: true,
- updatedAt: true
+ updatedAt: true,
+ avatarProvider: true,
+ avatarProviderUserId: true,
+ email: true
},
with: {
- identities: {
- columns: {
- provider: true,
- providerUsername: true
- },
- where: (identities, { eq }) => eq(identities.provider, 'github')
- }
+ identities: true
}
})
- // 处理用户列表,添加头像字段
- const formattedUsers = usersList.map((user) => ({
- ...user,
- avatar: user.identities?.[0]?.providerUsername
- ? `https://github.com/${user.identities[0].providerUsername}.png`
- : null
- }))
+ const { getUserAvatarUrl } = await import('../../../utils/user-avatar')
+
+ const formattedUsers = usersList.map((user) => {
+ const avatarUrl = getUserAvatarUrl(
+ {
+ id: user.id,
+ name: user.name,
+ username: user.username,
+ email: user.email,
+ avatarProvider: user.avatarProvider,
+ avatarProviderUserId: user.avatarProviderUserId
+ },
+ user.identities || []
+ )
+ return {
+ ...user,
+ avatar: avatarUrl,
+ avatarUrl
+ }
+ })
// 计算分页信息
const totalPages = Math.ceil(total / limitNum)
diff --git a/server/api/admin/users/index.post.ts b/server/api/admin/users/index.post.ts
index dd11826..de23c11 100644
--- a/server/api/admin/users/index.post.ts
+++ b/server/api/admin/users/index.post.ts
@@ -2,6 +2,7 @@ import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
+import { getRoleLevel, isRoleHigher, canManageUserRole } from '~~/server/utils/permissions.js'
export default defineEventHandler(async (event) => {
// 检查认证和权限
@@ -51,31 +52,21 @@ export default defineEventHandler(async (event) => {
// 加密密码
const hashedPassword = await bcrypt.hash(body.password, 10)
- // 角色权限控制
+ // 角色权限控制(统一使用 canManageUserRole 数值化级别检查)
let validRole = 'USER'
- if (body.role && ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN'].includes(body.role)) {
- // 超级管理员可以创建任何角色的用户
- if (user.role === 'SUPER_ADMIN') {
- validRole = body.role
- }
- // 管理员只能创建管理员以下的角色(USER, SONG_ADMIN)
- else if (user.role === 'ADMIN') {
- if (['USER', 'TEACHER', 'SONG_ADMIN'].includes(body.role)) {
- validRole = body.role
- } else {
- throw createError({
- statusCode: 403,
- message: '管理员只能创建用户、教师和歌曲管理员角色'
- })
- }
- }
- // 其他角色不能创建用户
- else {
+ const requestedRole = body.role
+ if (requestedRole) {
+ if (!canManageUserRole(user, requestedRole)) {
+ const currentLevel = getRoleLevel(user.role)
+ const targetLevel = getRoleLevel(requestedRole)
throw createError({
statusCode: 403,
- message: '没有权限创建用户'
+ message: isRoleHigher(requestedRole, user.role) || currentLevel <= targetLevel
+ ? `权限不足:${user.role} 无法创建级别高于或等于自身的角色(${requestedRole})`
+ : `没有权限创建 ${requestedRole} 角色的用户`
})
}
+ validRole = requestedRole
}
// 状态验证
diff --git a/server/utils/permissions.js b/server/utils/permissions.js
index a7830fd..2a3ac1e 100644
--- a/server/utils/permissions.js
+++ b/server/utils/permissions.js
@@ -85,18 +85,113 @@ function getRoleInfo(roleName) {
return ROLES[roleName] || null
}
-// 检查用户是否可以管理其他用户角色
+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 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'
}
- return false
+ 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
+}
diff --git a/server/utils/user-avatar.ts b/server/utils/user-avatar.ts
index de76771..9cf6fc3 100644
--- a/server/utils/user-avatar.ts
+++ b/server/utils/user-avatar.ts
@@ -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)
+ }
+}
--
2.45.1.windows.1