- 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 等高角色
2374 lines
87 KiB
Vue
2374 lines
87 KiB
Vue
<template>
|
||
<div class="max-w-[1400px] mx-auto space-y-6 pb-20 px-2">
|
||
<!-- Header -->
|
||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mt-4">
|
||
<div>
|
||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">用户管理</h2>
|
||
<p class="text-xs text-zinc-500 mt-1">系统共有 {{ totalUsers }} 位成员 · 权限与账户管理</p>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<button
|
||
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-lg transition-all uppercase tracking-widest active:scale-95 shadow-lg shadow-blue-900/20"
|
||
@click="showAddModal = true"
|
||
>
|
||
<UserPlus :size="14" />
|
||
添加
|
||
</button>
|
||
<button
|
||
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-900 border border-zinc-800 text-zinc-300 text-xs font-black rounded-lg transition-all uppercase tracking-widest"
|
||
@click="showImportModal = true"
|
||
>
|
||
<FileSpreadsheet class="text-emerald-500" :size="14" />
|
||
导入
|
||
</button>
|
||
<button
|
||
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-900 border border-zinc-800 text-zinc-300 text-xs font-black rounded-lg transition-all uppercase tracking-widest"
|
||
@click="showBatchUpdateModal = true"
|
||
>
|
||
<Layers class="text-purple-500" :size="14" />
|
||
更新
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Filter Bar -->
|
||
<div
|
||
class="bg-zinc-900/40 border border-zinc-800/60 rounded-xl p-3 flex flex-col lg:flex-row gap-3 items-center"
|
||
>
|
||
<div class="relative flex-1 w-full group">
|
||
<Search
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-blue-500 transition-colors"
|
||
:size="16"
|
||
/>
|
||
<input
|
||
v-model="searchQuery"
|
||
class="w-full bg-zinc-950 border border-zinc-800/80 rounded-lg pl-11 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
|
||
placeholder="通过姓名或学号搜索..."
|
||
type="text"
|
||
>
|
||
</div>
|
||
<div
|
||
class="flex items-center gap-2 w-full lg:w-auto overflow-x-auto custom-scrollbar no-scrollbar"
|
||
>
|
||
<!-- 角色筛选 -->
|
||
<CustomSelect
|
||
v-model="roleFilter"
|
||
:options="roleFilterOptions"
|
||
label="角色"
|
||
placeholder="全部角色"
|
||
label-key="displayName"
|
||
value-key="name"
|
||
class-name="flex-1 lg:w-40 min-w-[120px]"
|
||
/>
|
||
|
||
<!-- 状态筛选 -->
|
||
<CustomSelect
|
||
v-model="statusFilter"
|
||
:options="statusFilterOptions"
|
||
label="状态"
|
||
placeholder="全部状态"
|
||
label-key="label"
|
||
value-key="value"
|
||
class-name="flex-1 lg:w-32 min-w-[100px]"
|
||
/>
|
||
|
||
<!-- 排序 -->
|
||
<CustomSelect
|
||
v-model="currentSort"
|
||
:options="sortOptions"
|
||
label="排序"
|
||
placeholder="排序方式"
|
||
label-key="label"
|
||
value-key="value"
|
||
class-name="flex-1 lg:w-40 min-w-[140px]"
|
||
/>
|
||
|
||
<button
|
||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-600 hover:text-blue-400 transition-all shadow-sm"
|
||
@click="loadUsers(1)"
|
||
>
|
||
<RefreshCw :size="14" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 用户表格 -->
|
||
<div class="space-y-4">
|
||
<div v-if="loading" class="flex flex-col items-center justify-center py-20 text-zinc-500">
|
||
<div
|
||
class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin mb-4"
|
||
/>
|
||
<div class="text-xs font-black uppercase tracking-widest">正在加载用户...</div>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="users.length === 0"
|
||
class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||
>
|
||
<div
|
||
class="w-16 h-16 rounded-lg bg-zinc-800/50 flex items-center justify-center text-zinc-600 mb-4"
|
||
>
|
||
<Search :size="32" />
|
||
</div>
|
||
<div class="text-sm font-black text-zinc-500 uppercase tracking-widest">
|
||
{{ searchQuery ? '没有找到匹配的用户' : '暂无用户数据' }}
|
||
</div>
|
||
</div>
|
||
|
||
<template v-else>
|
||
<!-- 桌面端表格 -->
|
||
<div
|
||
class="hidden lg:block bg-zinc-900/20 border border-zinc-800/50 rounded-xl overflow-hidden shadow-lg custom-scrollbar"
|
||
>
|
||
<table class="w-full">
|
||
<thead>
|
||
<tr
|
||
class="bg-zinc-900/60 border-b border-zinc-800 text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||
>
|
||
<th class="px-6 py-5 text-left">用户详情</th>
|
||
<th class="px-6 py-5 text-left">角色权限</th>
|
||
<th class="px-6 py-5 text-left">账户状态</th>
|
||
<th class="px-6 py-5 text-center">所在班级</th>
|
||
<th class="px-6 py-5 text-left">最后交互</th>
|
||
<th class="px-6 py-5 text-right pr-10">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-zinc-800/40">
|
||
<tr
|
||
v-for="user in users"
|
||
:key="user.id"
|
||
class="group hover:bg-zinc-800/30 transition-all text-xs cursor-pointer"
|
||
@click="showUserDetail(user, $event)"
|
||
>
|
||
<td class="px-6 py-5">
|
||
<div class="flex items-center gap-4">
|
||
<img
|
||
v-if="user.avatar && !failedImages[user.id]"
|
||
:src="user.avatar"
|
||
class="w-10 h-10 rounded-xl object-cover border border-zinc-700/50"
|
||
@error="handleImageError(user.id)"
|
||
>
|
||
<div
|
||
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-zinc-300 transition-colors border border-zinc-700/50"
|
||
>
|
||
{{ (user.name || user.username || '?').charAt(0).toUpperCase() }}
|
||
</div>
|
||
<div>
|
||
<p class="font-black text-zinc-100">{{ user.name || user.username }}</p>
|
||
<p class="text-[10px] text-zinc-600 font-mono mt-0.5">
|
||
ID: {{ user.username }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-5">
|
||
<span
|
||
class="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
|
||
:class="{
|
||
'bg-orange-500/10 text-orange-400 border-orange-500/20': user.role?.toUpperCase() === 'SUPER_ADMIN',
|
||
'bg-blue-500/10 text-blue-400 border-blue-500/20': user.role?.toUpperCase() === 'ADMIN' || user.role?.toUpperCase() === 'MINISTER',
|
||
'bg-purple-500/10 text-purple-400 border-purple-500/20': user.role?.toUpperCase() === 'SONG_ADMIN' || user.role?.toUpperCase() === 'STAFF' || user.role?.toUpperCase() === 'VICE_MINISTER',
|
||
'bg-zinc-800 text-zinc-500 border-zinc-700/50': !['SUPER_ADMIN', 'ADMIN', 'MINISTER', 'SONG_ADMIN', 'STAFF', 'VICE_MINISTER'].includes(user.role?.toUpperCase())
|
||
}"
|
||
>
|
||
{{ getRoleDisplayName(user.role?.toUpperCase()) }}
|
||
</span>
|
||
</td>
|
||
<td class="px-6 py-5">
|
||
<div
|
||
v-if="user.status === 'active'"
|
||
class="flex items-center gap-1.5 text-emerald-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div
|
||
class="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"
|
||
/>
|
||
正常
|
||
</div>
|
||
<div
|
||
v-else-if="user.status === 'withdrawn'"
|
||
class="flex items-center gap-1.5 text-red-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||
退学
|
||
</div>
|
||
<div
|
||
v-else-if="user.status === 'graduate'"
|
||
class="flex items-center gap-1.5 text-amber-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||
毕业
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="flex items-center gap-1.5 text-zinc-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-zinc-500" />
|
||
禁用
|
||
</div>
|
||
</td>
|
||
<td class="px-6 py-5 text-center font-bold text-zinc-500">
|
||
{{ user.grade || '-' }} {{ user.class || '-' }}
|
||
</td>
|
||
<td class="px-6 py-5">
|
||
<p class="text-zinc-400 font-bold">{{ formatDate(user.lastLogin) }}</p>
|
||
<p class="text-[11px] text-zinc-700 font-mono mt-1 flex items-center gap-1">
|
||
<MapPin :size="10" /> {{ user.lastLoginIp || '-' }}
|
||
</p>
|
||
</td>
|
||
<td class="px-6 py-5 text-right pr-10">
|
||
<div
|
||
class="flex items-center justify-end gap-1.5 opacity-0 group-hover:opacity-100 transition-all action-buttons"
|
||
>
|
||
<button
|
||
: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="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
|
||
@click="editUser(user)"
|
||
>
|
||
<Edit2 :size="13" />
|
||
</button>
|
||
<button
|
||
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-purple-400 transition-colors action-btn"
|
||
title="查看歌曲"
|
||
@click="viewUserSongs(user)"
|
||
>
|
||
<Music :size="13" />
|
||
</button>
|
||
<button
|
||
: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="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
|
||
@click="resetPassword(user)"
|
||
>
|
||
<Lock :size="13" />
|
||
</button>
|
||
<button
|
||
: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="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
|
||
@click="confirmDeleteUser(user)"
|
||
>
|
||
<Trash2 :size="13" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- 移动端卡片式布局 -->
|
||
<div class="lg:hidden grid grid-cols-1 gap-4">
|
||
<div
|
||
v-for="user in users"
|
||
:key="user.id"
|
||
class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-5 space-y-5 shadow-lg shadow-black/20"
|
||
@click="showUserDetail(user, $event)"
|
||
>
|
||
<div class="flex items-start justify-between">
|
||
<div class="flex items-center gap-4">
|
||
<img
|
||
v-if="user.avatar && !failedImages[user.id]"
|
||
:src="user.avatar"
|
||
class="w-12 h-12 rounded-lg object-cover border border-zinc-700"
|
||
@error="handleImageError(user.id)"
|
||
>
|
||
<div
|
||
v-else
|
||
class="w-12 h-12 rounded-lg bg-zinc-800 flex items-center justify-center font-black text-lg text-zinc-500 border border-zinc-700"
|
||
>
|
||
{{ (user.name || user.username || '?').charAt(0).toUpperCase() }}
|
||
</div>
|
||
<div>
|
||
<h4 class="text-base font-black text-zinc-100">{{ user.name || user.username }}</h4>
|
||
<p class="text-xs text-zinc-500 font-mono">@{{ user.username }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="text-right">
|
||
<div
|
||
v-if="user.status === 'active'"
|
||
class="flex items-center gap-1.5 text-emerald-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div
|
||
class="w-1.5 h-1.5 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"
|
||
/>
|
||
正常
|
||
</div>
|
||
<div
|
||
v-else-if="user.status === 'withdrawn'"
|
||
class="flex items-center gap-1.5 text-red-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-red-500" />
|
||
退学
|
||
</div>
|
||
<div
|
||
v-else-if="user.status === 'graduate'"
|
||
class="flex items-center gap-1.5 text-amber-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-amber-500" />
|
||
毕业
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="flex items-center gap-1.5 text-zinc-500 font-black uppercase text-[10px] tracking-widest"
|
||
>
|
||
<div class="w-1.5 h-1.5 rounded-full bg-zinc-500" />
|
||
禁用
|
||
</div>
|
||
<p class="text-[10px] font-black text-zinc-700 uppercase mt-1">账户状态</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4 py-4 border-y border-zinc-800/50">
|
||
<div class="space-y-1">
|
||
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
||
角色/等级
|
||
</p>
|
||
<div>
|
||
<span
|
||
class="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
|
||
:class="{
|
||
'bg-orange-500/10 text-orange-400 border-orange-500/20': user.role?.toUpperCase() === 'SUPER_ADMIN',
|
||
'bg-blue-500/10 text-blue-400 border-blue-500/20': user.role?.toUpperCase() === 'ADMIN' || user.role?.toUpperCase() === 'MINISTER',
|
||
'bg-purple-500/10 text-purple-400 border-purple-500/20': user.role?.toUpperCase() === 'SONG_ADMIN' || user.role?.toUpperCase() === 'STAFF' || user.role?.toUpperCase() === 'VICE_MINISTER',
|
||
'bg-zinc-800 text-zinc-500 border-zinc-700/50': !['SUPER_ADMIN', 'ADMIN', 'MINISTER', 'SONG_ADMIN', 'STAFF', 'VICE_MINISTER'].includes(user.role?.toUpperCase())
|
||
}"
|
||
>
|
||
{{ getRoleDisplayName(user.role?.toUpperCase()) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-1 text-right">
|
||
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
||
所在班级
|
||
</p>
|
||
<p class="text-xs font-bold text-zinc-300">
|
||
{{ user.grade || '-' }} {{ user.class || '-' }}
|
||
</p>
|
||
</div>
|
||
<div class="space-y-1">
|
||
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
||
最近活动
|
||
</p>
|
||
<p class="text-xs font-bold text-zinc-400">{{ formatDate(user.lastLogin) }}</p>
|
||
</div>
|
||
<div class="space-y-1 text-right">
|
||
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
||
最后登录IP
|
||
</p>
|
||
<p class="text-[11px] font-mono text-zinc-600">{{ user.lastLoginIp || '-' }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-2 action-buttons">
|
||
<button
|
||
: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" /> 编辑
|
||
</button>
|
||
<button
|
||
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-purple-600 active:text-white transition-colors action-btn"
|
||
@click="viewUserSongs(user)"
|
||
>
|
||
<Music :size="12" /> 记录
|
||
</button>
|
||
<button
|
||
: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) || !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" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<Pagination
|
||
v-model:current-page="currentPage"
|
||
:total-pages="totalPages"
|
||
:total-items="totalUsers"
|
||
item-name="个用户"
|
||
@change="loadUsers"
|
||
/>
|
||
|
||
<!-- 添加/编辑用户模态框 -->
|
||
<Transition
|
||
enter-active-class="transition duration-300 ease-out"
|
||
enter-from-class="opacity-0 scale-95"
|
||
enter-to-class="opacity-100 scale-100"
|
||
leave-active-class="transition duration-200 ease-in"
|
||
leave-from-class="opacity-100 scale-100"
|
||
leave-to-class="opacity-0 scale-95"
|
||
>
|
||
<div
|
||
v-if="showAddModal || editingUser"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||
@click="closeModal"
|
||
>
|
||
<div
|
||
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl"
|
||
@click.stop
|
||
>
|
||
<div class="p-8">
|
||
<div class="flex items-center justify-between mb-8">
|
||
<div>
|
||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||
<div
|
||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||
>
|
||
<UserPlus v-if="!editingUser" :size="20" />
|
||
<Edit2 v-else :size="20" />
|
||
</div>
|
||
{{ editingUser ? '编辑用户信息' : '创建新用户' }}
|
||
</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 ml-13">请填写以下账户详细信息以继续</p>
|
||
</div>
|
||
<button
|
||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||
@click="closeModal"
|
||
>
|
||
<X :size="20" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="space-y-5">
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>姓名</label
|
||
>
|
||
<div class="relative group">
|
||
<User
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-blue-500 transition-colors"
|
||
:size="16"
|
||
/>
|
||
<input
|
||
v-model="userForm.name"
|
||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
|
||
placeholder="请输入真实姓名"
|
||
type="text"
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>用户名/学号</label
|
||
>
|
||
<div class="relative group">
|
||
<AtSign
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-blue-500 transition-colors"
|
||
:size="16"
|
||
/>
|
||
<input
|
||
v-model="userForm.username"
|
||
:disabled="!!editingUser"
|
||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 disabled:opacity-50"
|
||
placeholder="登录唯一标识"
|
||
type="text"
|
||
>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">
|
||
{{ editingUser ? '新密码 (留空则不修改)' : '初始密码' }}
|
||
</label>
|
||
<div class="relative group">
|
||
<Lock
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-blue-500 transition-colors"
|
||
:size="16"
|
||
/>
|
||
<input
|
||
v-model="userForm.password"
|
||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
|
||
placeholder="设置安全访问密码"
|
||
type="password"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>角色权限</label
|
||
>
|
||
<CustomSelect
|
||
v-model="userForm.role"
|
||
:options="availableRoles"
|
||
label-key="displayName"
|
||
value-key="name"
|
||
placeholder="请选择角色"
|
||
/>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>账户状态</label
|
||
>
|
||
<CustomSelect
|
||
v-model="userForm.status"
|
||
:options="userStatusOptions"
|
||
label-key="label"
|
||
value-key="value"
|
||
placeholder="请选择状态"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid grid-cols-2 gap-4">
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>年级</label
|
||
>
|
||
<CustomSelect
|
||
v-model="userForm.grade"
|
||
:options="gradeOptions"
|
||
label-key="label"
|
||
value-key="value"
|
||
placeholder="请选择年级"
|
||
class-name="w-full"
|
||
/>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>班级</label
|
||
>
|
||
<CustomSelect
|
||
v-model="userForm.class"
|
||
:options="classOptions"
|
||
label-key="label"
|
||
value-key="value"
|
||
placeholder="请选择班级"
|
||
class-name="w-full"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="formError"
|
||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs"
|
||
>
|
||
<AlertCircle :size="16" />
|
||
{{ formError }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 mt-10">
|
||
<button
|
||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||
@click="closeModal"
|
||
>
|
||
取消操作
|
||
</button>
|
||
<button
|
||
:disabled="saving"
|
||
class="flex-[2] px-6 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-blue-900/20 active:scale-95"
|
||
@click="saveUser"
|
||
>
|
||
<Save v-if="!saving" :size="16" />
|
||
<RefreshCw v-else class="animate-spin" :size="16" />
|
||
{{ saving ? '正在保存...' : editingUser ? '更新用户信息' : '确认创建用户' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- 重置密码模态框 -->
|
||
<Transition
|
||
enter-active-class="transition duration-300 ease-out"
|
||
enter-from-class="opacity-0 scale-95"
|
||
enter-to-class="opacity-100 scale-100"
|
||
leave-active-class="transition duration-200 ease-in"
|
||
leave-from-class="opacity-100 scale-100"
|
||
leave-to-class="opacity-0 scale-95"
|
||
>
|
||
<div
|
||
v-if="resetPasswordUser"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||
@click="closeResetPassword"
|
||
>
|
||
<div
|
||
class="bg-zinc-900 border border-zinc-800 w-full max-w-md rounded-3xl overflow-hidden shadow-2xl"
|
||
@click.stop
|
||
>
|
||
<div class="p-8 text-center">
|
||
<div
|
||
class="w-20 h-20 bg-amber-500/10 rounded-[2rem] flex items-center justify-center text-amber-500 mx-auto mb-6"
|
||
>
|
||
<Lock :size="32" />
|
||
</div>
|
||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置访问密码</h3>
|
||
<p class="text-xs text-zinc-500 mt-2 mb-8">
|
||
正在为
|
||
<span class="text-zinc-200 font-bold">{{ resetPasswordUser.name }}</span> 修改登录凭据
|
||
</p>
|
||
|
||
<div class="space-y-4 text-left">
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>新密码</label
|
||
>
|
||
<div class="relative group">
|
||
<Key
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-amber-500 transition-colors"
|
||
:size="16"
|
||
/>
|
||
<input
|
||
v-model="passwordForm.password"
|
||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200"
|
||
placeholder="设置高强度新密码"
|
||
type="password"
|
||
>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-2">
|
||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||
>确认新密码</label
|
||
>
|
||
<div class="relative group">
|
||
<Key
|
||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-amber-500 transition-colors"
|
||
:size="16"
|
||
/>
|
||
<input
|
||
v-model="passwordForm.confirmPassword"
|
||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200"
|
||
placeholder="请再次输入以确认"
|
||
type="password"
|
||
>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="passwordError"
|
||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs"
|
||
>
|
||
<AlertCircle :size="16" />
|
||
{{ passwordError }}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 mt-8">
|
||
<button
|
||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||
@click="closeResetPassword"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
:disabled="resetting"
|
||
class="flex-[2] px-6 py-4 bg-amber-600 hover:bg-amber-500 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-amber-900/20 active:scale-95"
|
||
@click="confirmResetPassword"
|
||
>
|
||
<Save v-if="!resetting" :size="16" />
|
||
<RefreshCw v-else class="animate-spin" :size="16" />
|
||
{{ resetting ? '正在重置...' : '确认重置密码' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- 批量导入用户模态框 -->
|
||
<Transition
|
||
enter-active-class="transition duration-300 ease-out"
|
||
enter-from-class="opacity-0 scale-95"
|
||
enter-to-class="opacity-100 scale-100"
|
||
leave-active-class="transition duration-200 ease-in"
|
||
leave-from-class="opacity-100 scale-100"
|
||
leave-to-class="opacity-0 scale-95"
|
||
>
|
||
<div
|
||
v-if="showImportModal"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||
@click="closeImportModal"
|
||
>
|
||
<div
|
||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl"
|
||
@click.stop
|
||
>
|
||
<div class="p-8">
|
||
<div class="flex items-center justify-between mb-8">
|
||
<div>
|
||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||
<div
|
||
class="w-10 h-10 rounded-2xl bg-emerald-600/10 flex items-center justify-center text-emerald-500"
|
||
>
|
||
<FileSpreadsheet :size="20" />
|
||
</div>
|
||
批量导入用户
|
||
</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 ml-13">
|
||
支持 .xlsx 格式文件,请按模板要求上传
|
||
</p>
|
||
</div>
|
||
<button
|
||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||
@click="closeImportModal"
|
||
>
|
||
<X :size="20" />
|
||
</button>
|
||
</div>
|
||
|
||
<div class="space-y-6 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
|
||
<div class="p-5 bg-zinc-950/50 border border-zinc-800/50 rounded-3xl space-y-4">
|
||
<div class="flex items-center gap-2 text-zinc-300 font-bold text-sm mb-2">
|
||
<Info :size="16" class="text-blue-400" />
|
||
导入说明
|
||
</div>
|
||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||
请上传Excel格式文件 (.xlsx),系统会自动解析数据。
|
||
注意:第一行可以是标题行(会自动跳过),角色字段必须匹配系统定义的角色。
|
||
</p>
|
||
|
||
<div class="overflow-hidden rounded-2xl border border-zinc-800/80">
|
||
<table class="w-full text-[10px] text-left">
|
||
<thead class="bg-zinc-900 text-zinc-400 uppercase tracking-tighter">
|
||
<tr>
|
||
<th class="px-3 py-2 border-b border-zinc-800">姓名</th>
|
||
<th class="px-3 py-2 border-b border-zinc-800">账号名</th>
|
||
<th class="px-3 py-2 border-b border-zinc-800">密码</th>
|
||
<th class="px-3 py-2 border-b border-zinc-800">角色</th>
|
||
<th class="px-3 py-2 border-b border-zinc-800">年级</th>
|
||
<th class="px-3 py-2 border-b border-zinc-800">班级</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="text-zinc-500">
|
||
<tr class="border-b border-zinc-900/50">
|
||
<td class="px-3 py-2">张三</td>
|
||
<td class="px-3 py-2">张三</td>
|
||
<td class="px-3 py-2">******</td>
|
||
<td class="px-3 py-2">USER</td>
|
||
<td class="px-3 py-2">高一</td>
|
||
<td class="px-3 py-2">1班</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div class="flex items-center justify-between gap-4">
|
||
<div
|
||
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-2xl text-[10px] text-blue-400/80 leading-relaxed flex-1"
|
||
>
|
||
<strong>支持的角色:</strong>
|
||
<span v-if="isSuperAdmin"
|
||
>USER(普通用户)、ADMIN(管理员)、SONG_ADMIN(歌曲管理员)、SUPER_ADMIN(超级管理员)</span
|
||
>
|
||
<span v-else>USER(普通用户)、SONG_ADMIN(歌曲管理员)</span>
|
||
</div>
|
||
<button
|
||
class="px-4 py-3 bg-emerald-500/10 border border-emerald-500/20 hover:border-emerald-500/40 rounded-xl transition-all flex items-center gap-2 group shrink-0"
|
||
@click="downloadImportTemplate"
|
||
>
|
||
<Download
|
||
:size="16"
|
||
class="text-emerald-500 group-hover:scale-110 transition-transform"
|
||
/>
|
||
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
|
||
>下载模板</span
|
||
>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="space-y-3">
|
||
<label class="block text-xs font-black text-zinc-400 uppercase tracking-widest ml-1"
|
||
>选择数据文件</label
|
||
>
|
||
<div class="relative group cursor-pointer" @click="$refs.fileInput.click()">
|
||
<input
|
||
id="file-upload"
|
||
ref="fileInput"
|
||
accept=".xlsx"
|
||
class="hidden"
|
||
type="file"
|
||
@change="handleFileUpload"
|
||
>
|
||
<div
|
||
class="w-full py-10 border-2 border-dashed border-zinc-800 group-hover:border-emerald-500/50 group-hover:bg-emerald-500/5 rounded-3xl transition-all flex flex-col items-center justify-center gap-3"
|
||
>
|
||
<div
|
||
class="w-12 h-12 rounded-2xl bg-zinc-900 flex items-center justify-center text-zinc-600 group-hover:text-emerald-500 transition-colors"
|
||
>
|
||
<Upload :size="24" />
|
||
</div>
|
||
<div class="text-center">
|
||
<p class="text-sm font-bold text-zinc-300">点击或拖拽上传 Excel 文件</p>
|
||
<p class="text-xs text-zinc-500 mt-1">仅支持 .xlsx 格式</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="importError"
|
||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs"
|
||
>
|
||
<AlertCircle :size="16" />
|
||
{{ importError }}
|
||
</div>
|
||
|
||
<div
|
||
v-if="importSuccess"
|
||
class="p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl flex items-center gap-3 text-emerald-400 text-xs"
|
||
>
|
||
<CheckCircle2 :size="16" />
|
||
{{ importSuccess }}
|
||
</div>
|
||
|
||
<div v-if="previewData.length > 0" class="space-y-3">
|
||
<div class="flex items-center justify-between ml-1">
|
||
<label class="text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||
>预览数据 ({{ previewData.length }}条记录)</label
|
||
>
|
||
</div>
|
||
<div class="overflow-hidden rounded-3xl border border-zinc-800 bg-zinc-950/30">
|
||
<table class="w-full text-xs text-left">
|
||
<thead class="bg-zinc-900/50 text-zinc-500">
|
||
<tr>
|
||
<th class="px-4 py-3 font-medium">姓名</th>
|
||
<th class="px-4 py-3 font-medium">账号</th>
|
||
<th class="px-4 py-3 font-medium">角色</th>
|
||
<th class="px-4 py-3 font-medium">年级/班级</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody class="divide-y divide-zinc-900">
|
||
<tr
|
||
v-for="(row, index) in previewData.slice(0, 5)"
|
||
:key="index"
|
||
class="text-zinc-300"
|
||
>
|
||
<td class="px-4 py-3">{{ row.name }}</td>
|
||
<td class="px-4 py-3">{{ row.username }}</td>
|
||
<td class="px-4 py-3">
|
||
<span
|
||
class="px-2 py-0.5 bg-zinc-800 rounded-md text-[10px] text-zinc-400 uppercase"
|
||
>{{ row.role }}</span
|
||
>
|
||
</td>
|
||
<td class="px-4 py-3 text-zinc-500">
|
||
{{ row.grade || '-' }} / {{ row.class || '-' }}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
<div
|
||
v-if="previewData.length > 5"
|
||
class="p-3 text-center border-t border-zinc-900 text-[10px] text-zinc-500 font-medium"
|
||
>
|
||
以及另外 {{ previewData.length - 5 }} 条记录...
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex gap-3 mt-8">
|
||
<button
|
||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||
@click="closeImportModal"
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
:disabled="importLoading || previewData.length === 0"
|
||
class="flex-[2] px-6 py-4 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-emerald-900/20 active:scale-95"
|
||
@click="importUsers"
|
||
>
|
||
<Save v-if="!importLoading" :size="16" />
|
||
<RefreshCw v-else class="animate-spin" :size="16" />
|
||
{{ importLoading ? '正在导入...' : '确认开始导入' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
|
||
<!-- 删除确认对话框 -->
|
||
<ConfirmDialog
|
||
:show="showDeleteModal"
|
||
title="确认删除用户"
|
||
:message="`确定要删除用户 "${deletingUser?.name}" 吗?此操作将永久移除该账户,不可撤销。`"
|
||
type="danger"
|
||
confirm-text="确认删除"
|
||
:loading="deleting"
|
||
@confirm="confirmDelete"
|
||
@close="closeDeleteModal"
|
||
/>
|
||
|
||
<!-- 批量更新模态框 -->
|
||
<BatchUpdateModal
|
||
:show="showBatchUpdateModal"
|
||
:users="users"
|
||
@close="closeBatchUpdateModal"
|
||
@update-success="handleBatchUpdateSuccess"
|
||
/>
|
||
|
||
<!-- 用户歌曲模态框 -->
|
||
<UserSongsModal
|
||
:show="showUserSongsModal"
|
||
:user-id="selectedUserId"
|
||
@close="closeUserSongsModal"
|
||
/>
|
||
|
||
<!-- 用户详细信息模态框 -->
|
||
<Transition
|
||
enter-active-class="transition duration-300 ease-out"
|
||
enter-from-class="opacity-0 scale-95"
|
||
enter-to-class="opacity-100 scale-100"
|
||
leave-active-class="transition duration-200 ease-in"
|
||
leave-from-class="opacity-100 scale-100"
|
||
leave-to-class="opacity-0 scale-95"
|
||
>
|
||
<div
|
||
v-if="showUserDetailModal"
|
||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||
@click="closeUserDetailModal"
|
||
>
|
||
<div
|
||
class="bg-zinc-900 border border-zinc-800 w-full max-w-3xl rounded-3xl overflow-hidden shadow-2xl"
|
||
@click.stop
|
||
>
|
||
<div class="p-8">
|
||
<div class="flex items-center justify-between mb-8">
|
||
<div>
|
||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||
<div
|
||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||
>
|
||
<User :size="20" />
|
||
</div>
|
||
用户详细信息
|
||
</h3>
|
||
<p class="text-xs text-zinc-500 mt-1 ml-13">查看完整的账户资料与操作记录</p>
|
||
</div>
|
||
<button
|
||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||
@click="closeUserDetailModal"
|
||
>
|
||
<X :size="20" />
|
||
</button>
|
||
</div>
|
||
|
||
<div
|
||
v-if="selectedUserDetail"
|
||
class="space-y-8 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar"
|
||
>
|
||
<!-- 基本信息 -->
|
||
<div class="space-y-4">
|
||
<div
|
||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest ml-1"
|
||
>
|
||
<Info :size="14" class="text-blue-500" />
|
||
基本信息
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
用户 ID
|
||
</div>
|
||
<div class="text-sm font-bold text-zinc-300">{{ selectedUserDetail.id }}</div>
|
||
</div>
|
||
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
姓名
|
||
</div>
|
||
<div class="text-sm font-bold text-zinc-300">{{ selectedUserDetail.name }}</div>
|
||
</div>
|
||
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
用户名
|
||
</div>
|
||
<div class="text-sm font-bold text-zinc-300">
|
||
{{ selectedUserDetail.username }}
|
||
</div>
|
||
</div>
|
||
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
角色权限
|
||
</div>
|
||
<div>
|
||
<span
|
||
:class="[
|
||
'px-2 py-0.5 rounded-md text-[10px] font-black uppercase tracking-widest',
|
||
getRoleClass(selectedUserDetail.role)
|
||
]"
|
||
>
|
||
{{ getRoleDisplayName(selectedUserDetail.role) }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
年级
|
||
</div>
|
||
<div class="text-sm font-bold text-zinc-300">
|
||
{{ selectedUserDetail.grade || '未设置' }}
|
||
</div>
|
||
</div>
|
||
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
班级
|
||
</div>
|
||
<div class="text-sm font-bold text-zinc-300">
|
||
{{ selectedUserDetail.class || '未设置' }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 账户状态 -->
|
||
<div class="space-y-4">
|
||
<div
|
||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest ml-1"
|
||
>
|
||
<Shield :size="14" class="text-emerald-500" />
|
||
账户状态
|
||
</div>
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div
|
||
class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl flex items-center justify-between"
|
||
>
|
||
<div class="space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
密码状态
|
||
</div>
|
||
<div
|
||
class="text-sm font-bold"
|
||
:class="
|
||
!selectedUserDetail.forcePasswordChange &&
|
||
selectedUserDetail.passwordChangedAt
|
||
? 'text-emerald-500'
|
||
: 'text-amber-500'
|
||
"
|
||
>
|
||
{{
|
||
!selectedUserDetail.forcePasswordChange &&
|
||
selectedUserDetail.passwordChangedAt
|
||
? '密码已修改'
|
||
: '需要修改密码'
|
||
}}
|
||
</div>
|
||
</div>
|
||
<div
|
||
:class="[
|
||
'w-8 h-8 rounded-xl flex items-center justify-center',
|
||
!selectedUserDetail.forcePasswordChange &&
|
||
selectedUserDetail.passwordChangedAt
|
||
? 'bg-emerald-500/10 text-emerald-500'
|
||
: 'bg-amber-500/10 text-amber-500'
|
||
]"
|
||
>
|
||
<CheckCircle2
|
||
v-if="
|
||
!selectedUserDetail.forcePasswordChange &&
|
||
selectedUserDetail.passwordChangedAt
|
||
"
|
||
:size="16"
|
||
/>
|
||
<AlertCircle v-else :size="16" />
|
||
</div>
|
||
</div>
|
||
<div
|
||
class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl flex items-center justify-between"
|
||
>
|
||
<div class="space-y-1">
|
||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||
MeoW 账号绑定
|
||
</div>
|
||
<div
|
||
class="text-sm font-bold"
|
||
:class="
|
||
selectedUserDetail.meowNickname ? 'text-emerald-500' : 'text-zinc-500'
|
||
"
|
||
>
|
||
{{
|
||
selectedUserDetail.meowNickname
|
||
? `已绑定: ${selectedUserDetail.meowNickname}`
|
||
: '未绑定'
|
||
}}
|
||
</div>
|
||
</div>
|
||
<div
|
||
:class="[
|
||
'w-8 h-8 rounded-xl flex items-center justify-center',
|
||
selectedUserDetail.meowNickname
|
||
? 'bg-emerald-500/10 text-emerald-500'
|
||
: 'bg-zinc-800 text-zinc-600'
|
||
]"
|
||
>
|
||
<AtSign :size="16" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 登录与时间 -->
|
||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||
<div class="space-y-4">
|
||
<div
|
||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest ml-1"
|
||
>
|
||
<Clock :size="14" class="text-purple-500" />
|
||
登录信息
|
||
</div>
|
||
<div class="space-y-3">
|
||
<div
|
||
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
|
||
>
|
||
<span class="text-zinc-500">最后登录</span>
|
||
<span class="text-zinc-300 font-medium">{{
|
||
formatDate(selectedUserDetail.lastLogin)
|
||
}}</span>
|
||
</div>
|
||
<div
|
||
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
|
||
>
|
||
<span class="text-zinc-500">登录 IP</span>
|
||
<span class="text-zinc-300 font-medium">{{
|
||
selectedUserDetail.lastLoginIp || '未知'
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="space-y-4">
|
||
<div
|
||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest ml-1"
|
||
>
|
||
<Calendar :size="14" class="text-amber-500" />
|
||
时间记录
|
||
</div>
|
||
<div class="space-y-3">
|
||
<div
|
||
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
|
||
>
|
||
<span class="text-zinc-500">创建时间</span>
|
||
<span class="text-zinc-300 font-medium">{{
|
||
formatDate(selectedUserDetail.createdAt)
|
||
}}</span>
|
||
</div>
|
||
<div
|
||
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
|
||
>
|
||
<span class="text-zinc-500">最近更新</span>
|
||
<span class="text-zinc-300 font-medium">{{
|
||
formatDate(selectedUserDetail.updatedAt)
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 状态变更日志 -->
|
||
<div class="space-y-4">
|
||
<div class="flex items-center justify-between ml-1">
|
||
<div
|
||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||
>
|
||
<History :size="14" class="text-emerald-500" />
|
||
状态变更日志
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="statusLogsLoading"
|
||
class="py-12 flex flex-col items-center justify-center text-zinc-600 gap-3"
|
||
>
|
||
<RefreshCw :size="24" class="animate-spin" />
|
||
<span class="text-[10px] font-black uppercase tracking-widest"
|
||
>正在加载记录...</span
|
||
>
|
||
</div>
|
||
|
||
<div
|
||
v-else-if="statusLogs.length === 0"
|
||
class="py-12 text-center bg-zinc-950/30 border border-zinc-800/50 rounded-3xl"
|
||
>
|
||
<p class="text-xs text-zinc-600">暂无状态变更记录</p>
|
||
</div>
|
||
|
||
<div v-else class="space-y-4">
|
||
<div
|
||
class="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-0.5 before:bg-zinc-800"
|
||
>
|
||
<div v-for="log in statusLogs" :key="log.id" class="relative">
|
||
<div
|
||
class="absolute -left-[23px] top-1.5 w-3 h-3 rounded-full bg-zinc-900 border-2 border-zinc-700 ring-4 ring-zinc-900"
|
||
/>
|
||
<div
|
||
class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-3"
|
||
>
|
||
<div class="flex items-center justify-between">
|
||
<div class="flex items-center gap-2">
|
||
<span
|
||
:class="[
|
||
'px-2 py-0.5 rounded-md text-[10px] font-black uppercase tracking-tighter',
|
||
getStatusClass(log.oldStatus)
|
||
]"
|
||
>
|
||
{{ log.oldStatusDisplay || '初始' }}
|
||
</span>
|
||
<ArrowRight :size="12" class="text-zinc-700" />
|
||
<span
|
||
:class="[
|
||
'px-2 py-0.5 rounded-md text-[10px] font-black uppercase tracking-tighter',
|
||
getStatusClass(log.newStatus)
|
||
]"
|
||
>
|
||
{{ log.newStatusDisplay }}
|
||
</span>
|
||
</div>
|
||
<span class="text-[10px] text-zinc-600 font-medium">{{
|
||
formatStatusLogDate(log.createdAt)
|
||
}}</span>
|
||
</div>
|
||
|
||
<div
|
||
v-if="log.reason"
|
||
class="text-xs text-zinc-400 bg-zinc-900/50 p-2.5 rounded-xl border border-zinc-800/30 leading-relaxed"
|
||
>
|
||
<span class="text-zinc-600 font-bold mr-1">原因:</span>
|
||
{{ log.reason }}
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2 text-[10px]">
|
||
<div
|
||
class="w-4 h-4 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-500"
|
||
>
|
||
<User :size="8" />
|
||
</div>
|
||
<span class="text-zinc-500">操作者:</span>
|
||
<span class="text-zinc-300 font-bold">{{
|
||
log.operator?.name || '系统'
|
||
}}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 分页 -->
|
||
<Pagination
|
||
v-model:current-page="statusLogsPagination.page"
|
||
:total-pages="statusLogsPagination.totalPages"
|
||
@change="loadStatusLogsPage"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="mt-8">
|
||
<button
|
||
class="w-full px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||
@click="closeUserDetailModal"
|
||
>
|
||
关闭窗口
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</Transition>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, ref, watch } from 'vue'
|
||
import { useAuth } from '~/composables/useAuth'
|
||
import { usePermissions, rolesConfig, fetchRolesConfig } from '~/composables/usePermissions'
|
||
import {
|
||
Check,
|
||
UserPlus,
|
||
FileSpreadsheet,
|
||
Layers,
|
||
Search,
|
||
ChevronDown,
|
||
RefreshCw,
|
||
MapPin,
|
||
Edit2,
|
||
Music,
|
||
Lock,
|
||
Trash2,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
X,
|
||
ArrowRight,
|
||
History,
|
||
AlertCircle,
|
||
CheckCircle2,
|
||
User,
|
||
Shield,
|
||
Clock,
|
||
ExternalLink,
|
||
Save,
|
||
Trash,
|
||
Info,
|
||
Key,
|
||
Database,
|
||
Eye,
|
||
Settings,
|
||
MoreVertical,
|
||
LogOut,
|
||
Mail,
|
||
Plus,
|
||
ArrowLeft,
|
||
Filter,
|
||
Download,
|
||
Upload,
|
||
UserCog,
|
||
ShieldAlert,
|
||
Calendar,
|
||
Clock3,
|
||
Hash,
|
||
AtSign,
|
||
Briefcase
|
||
} from '@lucide/vue'
|
||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||
import Pagination from '~/components/UI/Common/Pagination.vue'
|
||
import UserSongsModal from '~/components/Admin/UserSongsModal.vue'
|
||
import BatchUpdateModal from '~/components/Admin/BatchUpdateModal.vue'
|
||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||
|
||
// 响应式数据
|
||
const loading = ref(false)
|
||
const users = ref([])
|
||
const searchQuery = ref('')
|
||
const roleFilter = ref('')
|
||
const statusFilter = ref('')
|
||
const sortBy = ref('id')
|
||
const sortOrder = ref('asc')
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(50)
|
||
const totalUsers = ref(0)
|
||
const totalPages = ref(1)
|
||
|
||
const sortOptions = [
|
||
{ label: '默认排序 (ID)', value: 'id-asc' },
|
||
{ label: '名称 (A-Z)', value: 'name-asc' },
|
||
{ label: '名称 (Z-A)', value: 'name-desc' },
|
||
{ label: '最近登录', value: 'lastLogin-desc' },
|
||
{ label: '最早登录', value: 'lastLogin-asc' },
|
||
{ label: '最近注册', value: 'createdAt-desc' },
|
||
{ label: '最早注册', value: 'createdAt-asc' }
|
||
]
|
||
|
||
const currentSort = computed({
|
||
get: () => `${sortBy.value}-${sortOrder.value}`,
|
||
set: (val) => {
|
||
const [field, order] = val.split('-')
|
||
sortBy.value = field
|
||
sortOrder.value = order
|
||
}
|
||
})
|
||
|
||
// 动态角色定义
|
||
const allRoles = computed(() => {
|
||
if (!rolesConfig.value) {
|
||
return [
|
||
{ name: 'USER', displayName: '普通用户' },
|
||
{ name: 'TEACHER', displayName: '教师' },
|
||
{ name: 'SONG_ADMIN', displayName: '歌曲管理员' },
|
||
{ name: 'ADMIN', displayName: '管理员' },
|
||
{ name: 'SUPER_ADMIN', displayName: '超级管理员' }
|
||
]
|
||
}
|
||
return Object.keys(rolesConfig.value).map(key => ({
|
||
name: key,
|
||
displayName: rolesConfig.value[key].displayName || key
|
||
}))
|
||
})
|
||
|
||
// 筛选选项
|
||
const roleFilterOptions = computed(() => [{ name: '', displayName: '全部角色' }, ...allRoles.value])
|
||
|
||
const statusFilterOptions = [
|
||
{ label: '全部状态', value: '' },
|
||
{ label: '正常', value: 'active' },
|
||
{ label: '退学', value: 'withdrawn' },
|
||
{ label: '毕业生', value: 'graduate' }
|
||
]
|
||
|
||
const userStatusOptions = [
|
||
{ label: '正常访问', value: 'active' },
|
||
{ label: '限制访问 (退学)', value: 'withdrawn' },
|
||
{ label: '限制访问 (毕业生)', value: 'graduate' }
|
||
]
|
||
|
||
const gradeOptions = [
|
||
{ label: '高一', value: '高一' },
|
||
{ label: '高二', value: '高二' },
|
||
{ label: '高三', value: '高三' }
|
||
]
|
||
|
||
const classOptions = Array.from({ length: 16 }, (_, i) => ({
|
||
label: `${i + 1}班`,
|
||
value: (i + 1).toString()
|
||
}))
|
||
|
||
// 模态框状态
|
||
const showAddModal = ref(false)
|
||
const editingUser = ref(null)
|
||
const saving = ref(false)
|
||
const formError = ref('')
|
||
|
||
// 重置密码状态
|
||
const resetPasswordUser = ref(null)
|
||
const resetting = ref(false)
|
||
const passwordError = ref('')
|
||
|
||
// 批量导入状态
|
||
const showImportModal = ref(false)
|
||
const importLoading = ref(false)
|
||
const importError = ref('')
|
||
const importSuccess = ref('')
|
||
const previewData = ref([])
|
||
const xlsxLoaded = ref(false)
|
||
|
||
// 批量更新状态
|
||
const showBatchUpdateModal = ref(false)
|
||
|
||
// 删除确认状态
|
||
const showDeleteModal = ref(false)
|
||
const deletingUser = ref(null)
|
||
const deleting = ref(false)
|
||
|
||
// 用户歌曲模态框状态
|
||
const showUserSongsModal = ref(false)
|
||
const selectedUserId = ref(null)
|
||
|
||
// 头像加载错误状态管理
|
||
const failedImages = reactive({})
|
||
const handleImageError = (userId) => {
|
||
failedImages[userId] = true
|
||
}
|
||
|
||
// 用户详细信息模态框状态
|
||
const showUserDetailModal = ref(false)
|
||
const selectedUserDetail = ref(null)
|
||
|
||
// 状态变更日志模态框状态
|
||
|
||
const statusLogs = ref([])
|
||
const statusLogsLoading = ref(false)
|
||
const statusLogsError = ref('')
|
||
const statusLogsPagination = ref({
|
||
page: 1,
|
||
totalPages: 1,
|
||
hasNextPage: false,
|
||
hasPrevPage: false
|
||
})
|
||
|
||
// 表单数据
|
||
const userForm = ref({
|
||
name: '',
|
||
username: '',
|
||
password: '',
|
||
role: 'USER',
|
||
status: 'active',
|
||
grade: '',
|
||
class: ''
|
||
})
|
||
|
||
const passwordForm = ref({
|
||
password: '',
|
||
confirmPassword: ''
|
||
})
|
||
|
||
// 服务
|
||
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
|
||
}
|
||
|
||
// 计算属性
|
||
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')
|
||
}
|
||
// 普通 ADMIN/MINISTER 等只能分配级别严格低于自己的角色(SUPER_ADMIN 已在上层排除)
|
||
return allRoles.value.filter(
|
||
(role) =>
|
||
role.name !== 'SUPER_ADMIN' &&
|
||
getRoleLevel(role.name) < currentUserRoleLevel.value
|
||
)
|
||
})
|
||
|
||
// 由于使用服务器端分页,这些计算属性简化了
|
||
const filteredUsers = computed(() => {
|
||
return users.value
|
||
})
|
||
|
||
const paginatedUsers = computed(() => {
|
||
return users.value
|
||
})
|
||
|
||
// 监听搜索和过滤条件变化
|
||
watch(
|
||
[searchQuery, roleFilter, statusFilter, sortBy, sortOrder],
|
||
() => {
|
||
currentPage.value = 1
|
||
loadUsers(1, pageSize.value)
|
||
},
|
||
{ debounce: 300 }
|
||
)
|
||
|
||
// 监听页码变化
|
||
watch(currentPage, (newPage) => {
|
||
loadUsers(newPage, pageSize.value)
|
||
})
|
||
|
||
// 方法
|
||
const formatDate = (dateString) => {
|
||
if (!dateString) return '从未登录'
|
||
const date = new Date(dateString)
|
||
const now = new Date()
|
||
const diff = now - date
|
||
|
||
if (diff < 60000) return '刚刚'
|
||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||
if (diff < 86400000 * 7) return `${Math.floor(diff / 86400000)}天前`
|
||
|
||
return date.toLocaleDateString('zh-CN')
|
||
}
|
||
|
||
const getRoleClass = (role) => {
|
||
const classes = {
|
||
USER: 'user',
|
||
ADMIN: 'admin',
|
||
SONG_ADMIN: 'song-admin',
|
||
SUPER_ADMIN: 'super-admin'
|
||
}
|
||
return classes[role] || 'admin'
|
||
}
|
||
|
||
const getRoleDisplayName = (role) => {
|
||
if (rolesConfig.value && rolesConfig.value[role]) {
|
||
return rolesConfig.value[role].displayName
|
||
}
|
||
const names = {
|
||
USER: '普通用户',
|
||
ADMIN: '管理员',
|
||
SONG_ADMIN: '歌曲管理员',
|
||
SUPER_ADMIN: '超级管理员'
|
||
}
|
||
return names[role] || role
|
||
}
|
||
|
||
const getStatusClass = (status) => {
|
||
const classes = {
|
||
active: 'status-active',
|
||
withdrawn: 'status-withdrawn',
|
||
graduate: 'status-graduate'
|
||
}
|
||
return classes[status] || 'status-active'
|
||
}
|
||
|
||
const getStatusDisplayName = (status) => {
|
||
const names = {
|
||
active: '正常',
|
||
withdrawn: '退学',
|
||
graduate: '毕业生'
|
||
}
|
||
return names[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,
|
||
username: user.username,
|
||
password: '',
|
||
role: user.role,
|
||
status: user.status || 'active',
|
||
grade: user.grade || '',
|
||
class: user.class || ''
|
||
}
|
||
}
|
||
|
||
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: '',
|
||
confirmPassword: ''
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
const viewUserSongs = (user) => {
|
||
selectedUserId.value = user.id
|
||
showUserSongsModal.value = true
|
||
}
|
||
|
||
const closeUserSongsModal = () => {
|
||
showUserSongsModal.value = false
|
||
selectedUserId.value = null
|
||
}
|
||
|
||
const showUserDetail = (user, event) => {
|
||
// 检查点击的是否是操作按钮或其子元素
|
||
if (event.target.closest('.action-btn') || event.target.closest('.action-buttons')) {
|
||
return // 如果点击的是操作按钮,不触发详情弹窗
|
||
}
|
||
|
||
selectedUserDetail.value = user
|
||
showUserDetailModal.value = true
|
||
|
||
// 自动加载状态变更日志
|
||
loadStatusLogsPage(1)
|
||
}
|
||
|
||
const closeUserDetailModal = () => {
|
||
showUserDetailModal.value = false
|
||
selectedUserDetail.value = null
|
||
}
|
||
|
||
const closeBatchUpdateModal = () => {
|
||
showBatchUpdateModal.value = false
|
||
}
|
||
|
||
const handleBatchUpdateSuccess = async () => {
|
||
await loadUsers()
|
||
if (window.$showNotification) {
|
||
window.$showNotification('批量更新成功', 'success')
|
||
}
|
||
}
|
||
|
||
const closeDeleteModal = () => {
|
||
showDeleteModal.value = false
|
||
deletingUser.value = null
|
||
deleting.value = false
|
||
}
|
||
|
||
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
|
||
|
||
try {
|
||
await $fetch(`/api/admin/users/${deletingUser.value.id}`, {
|
||
method: 'DELETE',
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
await loadUsers()
|
||
closeDeleteModal()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification('用户删除成功', 'success')
|
||
}
|
||
} catch (error) {
|
||
console.error('删除用户失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('删除用户失败: ' + error.message, 'error')
|
||
}
|
||
} finally {
|
||
deleting.value = false
|
||
}
|
||
}
|
||
|
||
const closeModal = () => {
|
||
showAddModal.value = false
|
||
editingUser.value = null
|
||
formError.value = ''
|
||
userForm.value = {
|
||
name: '',
|
||
username: '',
|
||
password: '',
|
||
role: 'USER',
|
||
status: 'active',
|
||
grade: '',
|
||
class: ''
|
||
}
|
||
}
|
||
|
||
const closeResetPassword = () => {
|
||
resetPasswordUser.value = null
|
||
passwordError.value = ''
|
||
passwordForm.value = {
|
||
password: '',
|
||
confirmPassword: ''
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||
if (!chineseRegex.test(userForm.value.name) || !chineseRegex.test(userForm.value.username)) {
|
||
formError.value = '姓名和用户名只能包含汉字'
|
||
return
|
||
}
|
||
|
||
if (!editingUser.value && !userForm.value.password) {
|
||
formError.value = '请输入密码'
|
||
return
|
||
}
|
||
|
||
saving.value = true
|
||
formError.value = ''
|
||
|
||
try {
|
||
const userData = {
|
||
name: userForm.value.name,
|
||
username: userForm.value.username,
|
||
role: userForm.value.role,
|
||
status: userForm.value.status,
|
||
grade: userForm.value.grade,
|
||
class: userForm.value.class
|
||
}
|
||
|
||
if (userForm.value.password) {
|
||
userData.password = userForm.value.password
|
||
}
|
||
|
||
// 检查是否是权限更新
|
||
const isRoleUpdate = editingUser.value && editingUser.value.role !== userForm.value.role
|
||
const oldRole = editingUser.value?.role
|
||
const newRole = userForm.value.role
|
||
|
||
if (editingUser.value) {
|
||
await $fetch(`/api/admin/users/${editingUser.value.id}`, {
|
||
method: 'PUT',
|
||
body: userData,
|
||
...auth.getAuthConfig()
|
||
})
|
||
} else {
|
||
await $fetch('/api/admin/users', {
|
||
method: 'POST',
|
||
body: userData,
|
||
...auth.getAuthConfig()
|
||
})
|
||
}
|
||
|
||
// 如果是权限更新且当前用户是超级管理员,发送通知
|
||
if (isRoleUpdate && permissions.isSuperAdmin) {
|
||
try {
|
||
const roleNames = {
|
||
USER: '普通用户',
|
||
SONG_ADMIN: '歌曲管理员',
|
||
ADMIN: '管理员',
|
||
SUPER_ADMIN: '超级管理员'
|
||
}
|
||
|
||
const notificationMessage = `您的账户权限已由超级管理员更新:${roleNames[oldRole]} → ${roleNames[newRole]}`
|
||
|
||
await $fetch('/api/admin/notifications/send', {
|
||
method: 'POST',
|
||
body: {
|
||
userId: editingUser.value.id,
|
||
title: '权限变更通知',
|
||
message: notificationMessage,
|
||
type: 'system'
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
} catch (notificationError) {
|
||
console.error('发送权限变更通知失败:', notificationError)
|
||
// 不影响主要操作,只记录错误
|
||
}
|
||
}
|
||
|
||
await loadUsers()
|
||
closeModal()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification(editingUser.value ? '用户更新成功' : '用户创建成功', 'success')
|
||
}
|
||
} catch (error) {
|
||
console.error('保存用户失败:', error)
|
||
formError.value = error.message || '保存失败'
|
||
} finally {
|
||
saving.value = false
|
||
}
|
||
}
|
||
|
||
const confirmResetPassword = async () => {
|
||
// 保护:禁止重置自身密码
|
||
if (resetPasswordUser.value && isSelf(resetPasswordUser.value)) {
|
||
passwordError.value = '禁止在用户管理中重置自己的密码'
|
||
return
|
||
}
|
||
if (!passwordForm.value.password) {
|
||
passwordError.value = '请输入新密码'
|
||
return
|
||
}
|
||
|
||
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
|
||
passwordError.value = '两次输入的密码不一致'
|
||
return
|
||
}
|
||
|
||
resetting.value = true
|
||
passwordError.value = ''
|
||
|
||
try {
|
||
await $fetch(`/api/admin/users/${resetPasswordUser.value.id}/reset-password`, {
|
||
method: 'POST',
|
||
body: {
|
||
newPassword: passwordForm.value.password
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
closeResetPassword()
|
||
|
||
if (window.$showNotification) {
|
||
window.$showNotification('密码重置成功', 'success')
|
||
}
|
||
} catch (error) {
|
||
console.error('重置密码失败:', error)
|
||
passwordError.value = error.message || '重置失败'
|
||
} finally {
|
||
resetting.value = false
|
||
}
|
||
}
|
||
|
||
const loadUsers = async (page = 1, limit = 100) => {
|
||
loading.value = true
|
||
try {
|
||
const response = await $fetch('/api/admin/users', {
|
||
query: {
|
||
page: page.toString(),
|
||
limit: limit.toString(),
|
||
search: searchQuery.value || undefined,
|
||
role: roleFilter.value || undefined,
|
||
status: statusFilter.value || undefined,
|
||
sortBy: sortBy.value,
|
||
sortOrder: sortOrder.value,
|
||
t: Date.now()
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
// 处理分页响应数据
|
||
if (response.users) {
|
||
users.value = response.users
|
||
console.log('加载用户成功:', users.value.length)
|
||
// 更新分页信息
|
||
if (response.pagination) {
|
||
totalUsers.value = response.pagination.total
|
||
currentPage.value = response.pagination.page
|
||
totalPages.value = response.pagination.totalPages
|
||
}
|
||
} else {
|
||
users.value = []
|
||
console.warn('响应中没有用户数据')
|
||
}
|
||
} catch (error) {
|
||
console.error('加载用户失败:', error)
|
||
if (window.$showNotification) {
|
||
window.$showNotification('加载用户失败: ' + error.message, 'error')
|
||
}
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
// 批量导入相关方法
|
||
const closeImportModal = () => {
|
||
showImportModal.value = false
|
||
importError.value = ''
|
||
importSuccess.value = ''
|
||
previewData.value = []
|
||
// 清空文件输入
|
||
const fileInput = document.getElementById('file-upload')
|
||
if (fileInput) {
|
||
fileInput.value = ''
|
||
}
|
||
}
|
||
|
||
// 加载XLSX库
|
||
const loadXLSX = async () => {
|
||
if (typeof window !== 'undefined' && !xlsxLoaded.value) {
|
||
try {
|
||
// 使用多个可靠的CDN源,如果一个失败可以尝试另一个
|
||
const cdnUrls = [
|
||
'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js',
|
||
'https://unpkg.com/xlsx@0.18.5/dist/xlsx.full.min.js',
|
||
'https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js'
|
||
]
|
||
|
||
// 尝试加载第一个CDN
|
||
let loaded = false
|
||
for (const url of cdnUrls) {
|
||
if (loaded) break
|
||
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
const script = document.createElement('script')
|
||
script.src = url
|
||
script.async = true
|
||
|
||
script.onload = () => {
|
||
xlsxLoaded.value = true
|
||
loaded = true
|
||
console.log('XLSX库加载成功:', url)
|
||
resolve()
|
||
}
|
||
|
||
script.onerror = () => {
|
||
console.warn(`无法从 ${url} 加载XLSX库,尝试下一个源`)
|
||
reject()
|
||
}
|
||
|
||
document.head.appendChild(script)
|
||
})
|
||
} catch (e) {
|
||
// 这个CDN失败,继续尝试下一个
|
||
}
|
||
}
|
||
|
||
if (!loaded) {
|
||
throw new Error('所有XLSX库源加载失败')
|
||
}
|
||
} catch (err) {
|
||
console.error('加载XLSX库失败:', err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理文件上传
|
||
const handleFileUpload = async (event) => {
|
||
const file = event.target.files[0]
|
||
if (!file) return
|
||
|
||
importError.value = ''
|
||
importSuccess.value = ''
|
||
previewData.value = []
|
||
|
||
// 确保XLSX库已加载
|
||
if (!window.XLSX) {
|
||
await loadXLSX()
|
||
|
||
if (!window.XLSX) {
|
||
importError.value = '无法加载Excel处理库,请刷新页面重试'
|
||
return
|
||
}
|
||
}
|
||
|
||
try {
|
||
const reader = new FileReader()
|
||
|
||
reader.onload = (e) => {
|
||
try {
|
||
const data = new Uint8Array(e.target.result)
|
||
// 使用全局XLSX对象
|
||
const workbook = window.XLSX.read(data, { type: 'array' })
|
||
|
||
// 假设数据在第一个工作表中
|
||
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]
|
||
const jsonData = window.XLSX.utils.sheet_to_json(firstSheet, { header: 1 })
|
||
|
||
// 智能检测标题行
|
||
let startRow = 0
|
||
if (jsonData.length > 0 && jsonData[0]) {
|
||
const firstRow = jsonData[0]
|
||
// 检查第一行是否包含常见的标题关键词
|
||
const titleKeywords = [
|
||
'姓名',
|
||
'用户名',
|
||
'密码',
|
||
'角色',
|
||
'年级',
|
||
'班级',
|
||
'name',
|
||
'username',
|
||
'password',
|
||
'role',
|
||
'grade',
|
||
'class'
|
||
]
|
||
const isHeaderRow = firstRow.some(
|
||
(cell) =>
|
||
cell &&
|
||
titleKeywords.some((keyword) =>
|
||
cell.toString().toLowerCase().includes(keyword.toLowerCase())
|
||
)
|
||
)
|
||
|
||
if (isHeaderRow) {
|
||
startRow = 1
|
||
console.log('检测到标题行,从第二行开始解析')
|
||
} else {
|
||
console.log('未检测到标题行,从第一行开始解析')
|
||
}
|
||
}
|
||
|
||
const userData = []
|
||
|
||
for (let i = startRow; i < jsonData.length; i++) {
|
||
const row = jsonData[i]
|
||
if (!row || !row.length || !row[0]) continue // 跳过空行
|
||
|
||
let role = (row[3]?.toString() || '').toUpperCase()
|
||
|
||
// 根据当前用户权限过滤角色
|
||
if (!isSuperAdmin.value) {
|
||
// 普通管理员只能导入 USER / TEACHER / SONG_ADMIN 角色
|
||
if (role !== 'USER' && role !== 'TEACHER' && role !== 'SONG_ADMIN') {
|
||
role = 'USER' // 默认设为普通用户
|
||
}
|
||
}
|
||
|
||
// 确保角色有效
|
||
const validRoles = rolesConfig.value ? Object.keys(rolesConfig.value) : ['USER', 'TEACHER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN']
|
||
if (!validRoles.includes(role)) {
|
||
role = 'USER'
|
||
}
|
||
|
||
const name = row[0]?.toString() || ''
|
||
const username = row[1]?.toString() || ''
|
||
|
||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||
if (!chineseRegex.test(name) || !chineseRegex.test(username)) {
|
||
importError.value = `第 ${i + 1} 行数据无效: 姓名和账号名只能包含汉字`
|
||
previewData.value = []
|
||
return
|
||
}
|
||
|
||
userData.push({
|
||
name: name,
|
||
username: username,
|
||
password: row[2]?.toString() || '',
|
||
role: role,
|
||
grade: row[4]?.toString() || '',
|
||
class: row[5]?.toString() || ''
|
||
})
|
||
}
|
||
|
||
if (userData.length === 0) {
|
||
importError.value = '未找到有效数据'
|
||
return
|
||
}
|
||
|
||
previewData.value = userData
|
||
} catch (err) {
|
||
console.error('解析Excel出错:', err)
|
||
importError.value = '解析Excel文件失败: ' + (err.message || '未知错误')
|
||
}
|
||
}
|
||
|
||
reader.onerror = () => {
|
||
importError.value = '读取文件失败'
|
||
}
|
||
|
||
reader.readAsArrayBuffer(file)
|
||
} catch (err) {
|
||
console.error('处理Excel文件错误:', err)
|
||
importError.value = '处理Excel文件失败: ' + (err.message || '未知错误')
|
||
}
|
||
}
|
||
|
||
// 下载导入模板
|
||
const downloadImportTemplate = async () => {
|
||
if (!window.XLSX) {
|
||
await loadXLSX()
|
||
if (!window.XLSX) {
|
||
if (window.$showNotification) {
|
||
window.$showNotification('Excel处理库加载失败,请刷新页面后重试', 'error')
|
||
}
|
||
return
|
||
}
|
||
}
|
||
|
||
const templateData = [
|
||
{ 姓名: '张三', 账号名: 'zhangsan', 密码: '123456', 角色: 'USER', 年级: '高一', 班级: '1班' },
|
||
{ 姓名: '李四', 账号名: 'lisi', 密码: '123456', 角色: 'USER', 年级: '高一', 班级: '2班' }
|
||
]
|
||
|
||
const ws = window.XLSX.utils.json_to_sheet(templateData)
|
||
const wb = window.XLSX.utils.book_new()
|
||
window.XLSX.utils.book_append_sheet(wb, ws, '用户导入模板')
|
||
window.XLSX.writeFile(wb, '用户批量导入模板.xlsx')
|
||
}
|
||
|
||
// 批量导入用户
|
||
const importUsers = async () => {
|
||
if (previewData.value.length === 0) return
|
||
|
||
importLoading.value = true
|
||
importError.value = ''
|
||
importSuccess.value = ''
|
||
|
||
const dataToImport = [...previewData.value]
|
||
const batchSize = 50
|
||
const totalBatches = Math.ceil(dataToImport.length / batchSize)
|
||
let totalCreated = 0
|
||
let totalFailed = 0
|
||
|
||
try {
|
||
for (let i = 0; i < dataToImport.length; i += batchSize) {
|
||
const batch = dataToImport.slice(i, i + batchSize)
|
||
const currentBatch = Math.floor(i / batchSize) + 1
|
||
|
||
importSuccess.value = `正在导入:正在处理第 ${currentBatch} / ${totalBatches} 批数据...`
|
||
|
||
try {
|
||
const result = await $fetch('/api/admin/users/batch', {
|
||
method: 'POST',
|
||
body: {
|
||
users: batch
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
totalCreated += result.created || 0
|
||
totalFailed += result.failed || 0
|
||
} catch (batchErr) {
|
||
console.error(`第 ${currentBatch} 批导入失败:`, batchErr)
|
||
totalFailed += batch.length
|
||
}
|
||
}
|
||
|
||
await loadUsers()
|
||
|
||
if (totalCreated > 0 || totalFailed === 0) {
|
||
importSuccess.value = `成功导入 ${totalCreated} 个用户,${totalFailed} 个用户导入失败`
|
||
previewData.value = []
|
||
|
||
setTimeout(() => {
|
||
if (importSuccess.value) {
|
||
closeImportModal()
|
||
}
|
||
}, 3000)
|
||
} else {
|
||
importError.value = '导入失败,请检查数据格式后重试'
|
||
importSuccess.value = ''
|
||
}
|
||
} catch (err) {
|
||
importError.value = `导入过程中发生错误 (已成功导入 ${totalCreated} 个): ${err.message || '未知错误'}`
|
||
importSuccess.value = ''
|
||
console.error('导入用户出错:', err)
|
||
} finally {
|
||
importLoading.value = false
|
||
}
|
||
}
|
||
|
||
// 状态日志相关方法
|
||
|
||
const loadStatusLogsPage = async (page) => {
|
||
if (!selectedUserDetail.value) return
|
||
|
||
statusLogsLoading.value = true
|
||
statusLogsError.value = ''
|
||
|
||
try {
|
||
const response = await $fetch(`/api/admin/users/${selectedUserDetail.value.id}/status-logs`, {
|
||
query: {
|
||
page: page.toString(),
|
||
limit: '10',
|
||
t: Date.now()
|
||
},
|
||
...auth.getAuthConfig()
|
||
})
|
||
|
||
statusLogs.value = response.logs || []
|
||
statusLogsPagination.value = response.pagination || {
|
||
page: 1,
|
||
totalPages: 1,
|
||
hasNextPage: false,
|
||
hasPrevPage: false
|
||
}
|
||
} catch (error) {
|
||
console.error('加载状态日志失败:', error)
|
||
statusLogsError.value = error.message || '加载状态日志失败'
|
||
} finally {
|
||
statusLogsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const formatStatusLogDate = (dateString) => {
|
||
if (!dateString) return '未知时间'
|
||
const date = new Date(dateString)
|
||
return date.toLocaleString('zh-CN')
|
||
}
|
||
|
||
// 生命周期
|
||
onMounted(async () => {
|
||
console.log('UserManager 组件挂载')
|
||
await loadUsers(1, pageSize.value)
|
||
// 预加载XLSX库
|
||
loadXLSX()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* 自定义滚动条样式 */
|
||
.custom-scrollbar::-webkit-scrollbar {
|
||
width: 6px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-track {
|
||
background: transparent;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||
background: #4a5568;
|
||
border-radius: 3px;
|
||
}
|
||
|
||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||
background: #718096;
|
||
}
|
||
|
||
/* 确保模态框中的表格在移动端可以滚动 */
|
||
.overflow-x-auto {
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
</style>
|