Files
BYW-VoiceHub/app/components/Admin/UserManager.vue
T

3072 lines
116 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-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ showArchived ? formatMessage(locale.archivedSubtitle, archivedCount) : formatMessage(locale.subtitle, 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-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-lg transition-all uppercase tracking-widest active:scale-95 shadow-lg shadow-[var(--primary-glow)]"
@click="showAddModal = true"
>
<UserPlus :size="14" />
{{ locale.add }}
</button>
<button
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-bg-secondary border border-border-secondary text-text-secondary text-xs font-black rounded-lg transition-all uppercase tracking-widest"
@click="showImportModal = true"
>
<FileSpreadsheet class="text-success" :size="14" />
{{ locale.import }}
</button>
<button
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-bg-secondary border border-border-secondary text-text-secondary text-xs font-black rounded-lg transition-all uppercase tracking-widest"
@click="showBatchUpdateModal = true"
>
<Layers class="text-info" :size="14" />
{{ locale.update }}
</button>
</div>
</div>
<!-- Filter Bar -->
<div
class="bg-bg-secondary-40 border border-border-secondary-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-text-secondary group-focus-within:text-primary transition-colors"
:size="16"
/>
<input
v-model="searchQuery"
class="w-full bg-bg-primary border border-border-secondary-80 rounded-lg pl-11 pr-4 py-2.5 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
:placeholder="locale.searchPlaceholder"
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="locale.role"
:placeholder="locale.allRoles"
label-key="displayName"
value-key="name"
class-name="flex-1 lg:w-40 min-w-[120px]"
/>
<!-- 状态筛选 -->
<CustomSelect
v-model="statusFilter"
:options="statusFilterOptions"
:label="locale.status"
:placeholder="locale.allStatus"
label-key="label"
value-key="value"
class-name="flex-1 lg:w-32 min-w-[100px]"
/>
<!-- 排序 -->
<CustomSelect
v-model="currentSort"
:options="sortOptions"
:label="locale.sort"
:placeholder="locale.sortPlaceholder"
label-key="label"
value-key="value"
class-name="flex-1 lg:w-40 min-w-[140px]"
/>
<button
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-disabled hover:text-primary transition-all shadow-sm flex items-center justify-center"
@click="loadUsers(1)"
>
<RefreshCw :size="14" />
</button>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-[360px_minmax(0,1fr)] gap-4 items-start">
<section
class="bg-bg-secondary-30 border border-border-secondary-60 rounded-xl overflow-hidden shadow-lg min-w-0"
>
<div class="p-4 border-b border-border-secondary-60 flex items-center justify-between gap-3">
<div>
<h3 class="text-sm font-black text-text-primary tracking-tight">{{ locale.organization.title }}</h3>
<p class="text-[10px] text-text-disabled mt-1">
{{ formatMessage(locale.organization.subtitle, treeUsers.length) }}
</p>
</div>
<button
class="p-2 bg-bg-primary border border-border-secondary rounded-lg text-text-disabled hover:text-primary transition-all flex items-center justify-center"
:title="locale.organization.refresh"
@click="loadUserTree"
>
<RefreshCw :size="14" :class="{ 'animate-spin': treeLoading }" />
</button>
</div>
<div
v-if="activeOrgFilterLabel"
class="mx-4 mt-4 p-3 bg-primary-10 border border-primary-20 rounded-lg flex items-center justify-between gap-3"
>
<div class="min-w-0">
<p class="text-[10px] font-black text-primary uppercase tracking-widest">{{ locale.organization.currentScope }}</p>
<p class="text-xs font-bold text-text-primary truncate">{{ activeOrgFilterLabel }}</p>
</div>
<button
class="text-[10px] font-black text-primary hover:text-text-primary transition-colors shrink-0"
@click="clearTreeFilter"
>
{{ locale.organization.clear }}
</button>
</div>
<div v-if="treeLoading" class="py-12 flex flex-col items-center justify-center text-text-disabled">
<RefreshCw :size="22" class="animate-spin mb-3" />
<span class="text-[10px] font-black uppercase tracking-widest">{{ locale.organization.loading }}</span>
</div>
<div
v-else-if="treeError"
class="m-4 p-4 bg-error-10 border border-error-20 rounded-lg text-xs text-error leading-relaxed"
>
{{ treeError }}
</div>
<div v-else-if="userTree.length === 0" class="py-12 text-center text-xs text-text-disabled">
{{ locale.organization.empty }}
</div>
<div v-else class="p-3 max-h-[680px] overflow-y-auto custom-scrollbar">
<div v-for="stage in userTree" :key="stage.key" class="tree-group">
<div class="flex items-center gap-1">
<button class="tree-toggle" @click="toggleTreeNode(stage.key)">
<ChevronDown v-if="isTreeNodeExpanded(stage.key)" :size="13" />
<ChevronRight v-else :size="13" />
</button>
<button
class="tree-label font-black text-text-primary"
:class="isStageFilterActive(stage.label) ? 'tree-label-active' : ''"
@click="handleStageClick(stage)"
>
<span class="truncate">{{ stage.label }}</span>
<span class="tree-count">{{ formatMessage(locale.organization.count, stage.count) }}</span>
</button>
</div>
<div v-if="isTreeNodeExpanded(stage.key)" class="tree-branch">
<div v-for="grade in stage.grades" :key="grade.key" class="tree-group">
<div class="flex items-center gap-1">
<button class="tree-toggle" @click="toggleTreeNode(grade.key)">
<ChevronDown v-if="isTreeNodeExpanded(grade.key)" :size="13" />
<ChevronRight v-else :size="13" />
</button>
<button
class="tree-label"
:class="
isTreeFilterActive(stage.label, grade.label, '') ? 'tree-label-active' : ''
"
@click="applyTreeFilter(grade.label, '', stage.label)"
>
<span class="truncate">{{ grade.label }}</span>
<span class="tree-count">{{ formatMessage(locale.organization.count, grade.count) }}</span>
</button>
</div>
<div v-if="isTreeNodeExpanded(grade.key)" class="tree-branch">
<div v-for="classNode in grade.classes" :key="classNode.key" class="tree-group">
<div class="flex items-center gap-1">
<button class="tree-toggle" @click="toggleTreeNode(classNode.key)">
<ChevronDown v-if="isTreeNodeExpanded(classNode.key)" :size="13" />
<ChevronRight v-else :size="13" />
</button>
<button
class="tree-label"
:class="
isTreeFilterActive(stage.label, grade.label, classNode.label)
? 'tree-label-active'
: ''
"
@click="applyTreeFilter(grade.label, classNode.label, stage.label)"
>
<span class="truncate">{{ classNode.label }}</span>
<span class="tree-count">{{ formatMessage(locale.organization.count, classNode.count) }}</span>
</button>
</div>
<div v-if="isTreeNodeExpanded(classNode.key)" class="tree-users">
<button
v-for="treeUser in classNode.users"
:key="treeUser.id"
class="tree-user"
@click="openTreeUser(treeUser)"
>
<User :size="12" class="text-text-disabled shrink-0" />
<span class="truncate text-text-secondary">{{ getUserDisplayName(treeUser) }}</span>
<span class="truncate text-text-disabled font-mono">@{{ treeUser.username }}</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 用户表格 -->
<div class="space-y-4 min-w-0">
<div v-if="loading" class="flex flex-col items-center justify-center py-20 text-text-tertiary">
<AppSpinner :size="32" class="mb-4" />
<div class="text-xs font-black uppercase tracking-widest">{{ locale.loading }}</div>
</div>
<div
v-else-if="users.length === 0"
class="flex flex-col items-center justify-center py-20 bg-bg-secondary-20 border border-border-secondary-50 rounded-xl"
>
<div
class="w-16 h-16 rounded-lg bg-bg-tertiary-50 flex items-center justify-center text-text-disabled mb-4"
>
<Search :size="32" />
</div>
<div class="text-sm font-black text-text-tertiary uppercase tracking-widest">
{{ searchQuery ? locale.emptySearch : locale.empty }}
</div>
</div>
<template v-else>
<!-- 桌面端表格 -->
<div
class="hidden lg:block bg-bg-secondary-20 border border-border-secondary-50 rounded-xl overflow-hidden shadow-lg custom-scrollbar"
>
<table class="w-full">
<thead>
<tr
class="bg-bg-secondary-60 border-b border-border-secondary text-[10px] font-black text-text-disabled uppercase tracking-widest"
>
<th class="px-6 py-5 text-left">{{ locale.table.userDetails }}</th>
<th class="px-6 py-5 text-left">{{ locale.table.role }}</th>
<th class="px-6 py-5 text-left">{{ locale.table.status }}</th>
<th class="px-6 py-5 text-center">{{ locale.table.class }}</th>
<th class="px-6 py-5 text-left">{{ locale.table.lastActivity }}</th>
<th class="px-6 py-5 text-right pr-10">{{ locale.table.actions }}</th>
</tr>
</thead>
<tbody class="divide-y divide-panel-bg-dark-40">
<tr
v-for="user in users"
:key="user.id"
class="group hover:bg-bg-tertiary-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-border-tertiary-50"
@error="handleImageError(user.id)"
>
<div
v-else
class="w-10 h-10 rounded-xl bg-bg-tertiary flex items-center justify-center font-black text-text-tertiary group-hover:text-text-secondary transition-colors border border-border-tertiary-50"
>
{{ getUserInitial(user) }}
</div>
<div>
<p class="font-black text-text-primary">{{ getUserDisplayName(user) }}</p>
<p class="text-[10px] text-text-disabled font-mono mt-0.5">
ID: {{ user.username }}
</p>
</div>
</div>
</td>
<td class="px-6 py-5">
<span
v-if="user.role === 'SUPER_ADMIN'"
class="px-2 py-0.5 bg-warning-10 text-warning text-[10px] font-black rounded border border-warning-20 uppercase tracking-widest"
>{{ getRoleName('SUPER_ADMIN') }}</span
>
<span
v-else-if="user.role === 'ADMIN'"
class="px-2 py-0.5 bg-primary-10 text-primary text-[10px] font-black rounded border border-primary-20 uppercase tracking-widest"
>{{ getRoleName('ADMIN') }}</span
>
<span
v-else-if="user.role === 'SONG_ADMIN'"
class="px-2 py-0.5 bg-info-10 text-info text-[10px] font-black rounded border border-info-20 uppercase tracking-widest"
>{{ getRoleName('SONG_ADMIN') }}</span
>
<span
v-else
class="px-2 py-0.5 bg-bg-tertiary text-text-tertiary text-[10px] font-black rounded border border-border-tertiary-50 uppercase tracking-widest"
>{{ getRoleName('USER') }}</span
>
</td>
<td class="px-6 py-5">
<div
v-if="user.status === 'active'"
class="flex items-center gap-1.5 text-success font-black uppercase text-[10px] tracking-widest"
>
<div
class="w-1.5 h-1.5 rounded-full bg-success shadow-[0_0_8px_var(--success-50)]"
/>
{{ getStatusName('active') }}
</div>
<div
v-else-if="user.status === 'pending'"
class="flex items-center gap-1.5 text-info font-black uppercase text-[10px] tracking-widest"
>
<div
class="w-1.5 h-1.5 rounded-full bg-info shadow-[0_0_8px_var(--info-50)]"
/>
{{ getStatusName('pending') }}
</div>
<div
v-else-if="user.status === 'withdrawn'"
class="flex items-center gap-1.5 text-error font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-error" />
{{ getStatusName('withdrawn') }}
</div>
<div
v-else-if="user.status === 'graduate'"
class="flex items-center gap-1.5 text-warning font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-warning" />
{{ getStatusName('graduate') }}
</div>
<div
v-else
class="flex items-center gap-1.5 text-text-tertiary font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-bg-quaternary" />
{{ getStatusName('disabled') }}
</div>
</td>
<td class="px-6 py-5 text-center font-bold text-text-tertiary">
{{ user.grade || '-' }} {{ user.class || '-' }}
</td>
<td class="px-6 py-5">
<p class="text-text-tertiary font-bold">{{ formatDate(user.lastLogin) }}</p>
<p class="text-[11px] text-text-secondary 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="!canManageUser(user)"
class="p-2 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-primary transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
:title="locale.actions.editUser"
@click="editUser(user)"
>
<Edit2 :size="13" />
</button>
<button
v-if="user.status === 'pending'"
class="p-2 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-info transition-colors action-btn flex items-center justify-center"
:title="locale.actions.approve"
@click="openApproval(user)"
>
<ClipboardCheck :size="13" />
</button>
<button
class="p-2 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-info transition-colors action-btn flex items-center justify-center"
:title="locale.actions.viewSongs"
@click="viewUserSongs(user)"
>
<Music :size="13" />
</button>
<button
:disabled="!canManageUser(user)"
class="p-2 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-warning transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn flex items-center justify-center"
:title="locale.actions.resetPassword"
@click="resetPassword(user)"
>
<Lock :size="13" />
</button>
<button
:disabled="!canManageUser(user)"
class="p-2 bg-bg-primary border border-border-secondary rounded-xl text-text-tertiary hover:text-error transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
:title="locale.actions.deleteUser"
@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-bg-secondary-40 border border-border-secondary rounded-xl p-5 space-y-5 shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
@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-border-tertiary"
@error="handleImageError(user.id)"
>
<div
v-else
class="w-12 h-12 rounded-lg bg-bg-tertiary flex items-center justify-center font-black text-lg text-text-tertiary border border-border-tertiary"
>
{{ getUserInitial(user) }}
</div>
<div>
<h4 class="text-base font-black text-text-primary">{{ getUserDisplayName(user) }}</h4>
<p class="text-xs text-text-tertiary 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-success font-black uppercase text-[10px] tracking-widest"
>
<div
class="w-1.5 h-1.5 rounded-full bg-success shadow-[0_0_8px_var(--success-50)]"
/>
{{ getStatusName('active') }}
</div>
<div
v-else-if="user.status === 'pending'"
class="flex items-center gap-1.5 text-info font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-info" />
{{ getStatusName('pending') }}
</div>
<div
v-else-if="user.status === 'withdrawn'"
class="flex items-center gap-1.5 text-error font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-error" />
{{ getStatusName('withdrawn') }}
</div>
<div
v-else-if="user.status === 'graduate'"
class="flex items-center gap-1.5 text-warning font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-warning" />
{{ getStatusName('graduate') }}
</div>
<div
v-else
class="flex items-center gap-1.5 text-text-tertiary font-black uppercase text-[10px] tracking-widest"
>
<div class="w-1.5 h-1.5 rounded-full bg-bg-quaternary" />
{{ getStatusName('disabled') }}
</div>
<p class="text-[10px] font-black text-text-secondary uppercase mt-1">{{ locale.mobile.accountStatus }}</p>
</div>
</div>
<div class="grid grid-cols-2 gap-4 py-4 border-y border-border-secondary-50">
<div class="space-y-1">
<p class="text-[9px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.mobile.roleLevel }}
</p>
<div>
<span
v-if="user.role === 'SUPER_ADMIN'"
class="px-2 py-0.5 bg-warning-10 text-warning text-[10px] font-black rounded border border-warning-20 uppercase tracking-widest"
>{{ getRoleName('SUPER_ADMIN') }}</span
>
<span
v-else-if="user.role === 'ADMIN'"
class="px-2 py-0.5 bg-primary-10 text-primary text-[10px] font-black rounded border border-primary-20 uppercase tracking-widest"
>{{ getRoleName('ADMIN') }}</span
>
<span
v-else-if="user.role === 'SONG_ADMIN'"
class="px-2 py-0.5 bg-info-10 text-info text-[10px] font-black rounded border border-info-20 uppercase tracking-widest"
>{{ getRoleName('SONG_ADMIN') }}</span
>
<span
v-else
class="px-2 py-0.5 bg-bg-tertiary text-text-tertiary text-[10px] font-black rounded border border-border-tertiary-50 uppercase tracking-widest"
>{{ getRoleName('USER') }}</span
>
</div>
</div>
<div class="space-y-1 text-right">
<p class="text-[9px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.mobile.class }}
</p>
<p class="text-xs font-bold text-text-secondary">
{{ user.grade || '-' }} {{ user.class || '-' }}
</p>
</div>
<div class="space-y-1">
<p class="text-[9px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.mobile.recentActivity }}
</p>
<p class="text-xs font-bold text-text-tertiary">{{ formatDate(user.lastLogin) }}</p>
</div>
<div class="space-y-1 text-right">
<p class="text-[9px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.mobile.lastLoginIp }}
</p>
<p class="text-[11px] font-mono text-text-disabled">{{ user.lastLoginIp || '-' }}</p>
</div>
</div>
<div class="flex gap-2 action-buttons">
<button
:disabled="!canManageUser(user)"
class="flex-1 py-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-primary-hover active:text-text-primary transition-colors disabled:opacity-20 action-btn"
@click="editUser(user)"
>
<Edit2 :size="12" /> {{ locale.actions.edit }}
</button>
<button
v-if="user.status === 'pending'"
class="flex-1 py-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-info active:text-text-primary transition-colors action-btn"
@click="openApproval(user)"
>
<ClipboardCheck :size="12" /> {{ locale.actions.approve }}
</button>
<button
class="flex-1 py-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-info active:text-text-primary transition-colors action-btn"
@click="viewUserSongs(user)"
>
<Music :size="12" /> {{ locale.actions.records }}
</button>
<button
:disabled="!canManageUser(user)"
class="flex-1 py-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-warning active:text-text-primary transition-colors disabled:opacity-20 action-btn"
@click="resetPassword(user)"
>
<Lock :size="12" /> {{ locale.actions.reset }}
</button>
<button
:disabled="!canManageUser(user)"
class="px-3 py-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary active:bg-error active:text-text-primary transition-colors disabled:opacity-20 action-btn"
@click="confirmDeleteUser(user)"
>
<Trash2 :size="14" />
</button>
</div>
</div>
</div>
</template>
<!-- 分页 -->
<Pagination
v-model:current-page="currentPage"
:total-pages="totalPages"
:total-items="totalUsers"
:item-name="locale.itemName"
/>
</div>
</div>
<!-- 添加/编辑用户模态框 -->
<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-bg-primary-80 backdrop-blur-sm"
>
<div
class="bg-bg-secondary border border-border-secondary 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-text-primary tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
>
<UserPlus v-if="!editingUser" :size="20" />
<Edit2 v-else :size="20" />
</div>
{{ editingUser ? locale.form.editTitle : locale.form.createTitle }}
</h3>
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.form.desc }}</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.form.name }}</label
>
<div class="relative group">
<User
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-primary transition-colors"
:size="16"
/>
<input
v-model="userForm.name"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
:placeholder="locale.form.namePlaceholder"
type="text"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.form.username }}</label
>
<div class="relative group">
<AtSign
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-primary transition-colors"
:size="16"
/>
<input
v-model="userForm.username"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
:placeholder="locale.form.usernamePlaceholder"
type="text"
>
</div>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1">
{{ editingUser ? locale.form.newPassword : locale.form.initialPassword }}
</label>
<div class="relative group">
<Lock
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-primary transition-colors"
:size="16"
/>
<input
v-model="userForm.password"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
:placeholder="locale.form.passwordPlaceholder"
type="password"
>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.form.role }}</label
>
<CustomSelect
v-model="userForm.role"
:options="availableRoles"
label-key="displayName"
value-key="name"
:placeholder="locale.form.rolePlaceholder"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.form.status }}</label
>
<CustomSelect
v-model="userForm.status"
:options="userStatusOptions"
label-key="label"
value-key="value"
:placeholder="locale.form.statusPlaceholder"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.form.grade }}</label
>
<div class="relative group">
<Calendar
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-primary transition-colors"
:size="16"
/>
<input
v-model="userForm.grade"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
:placeholder="locale.form.gradePlaceholder"
type="text"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.form.class }}</label
>
<div class="relative group">
<Briefcase
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-primary transition-colors"
:size="16"
/>
<input
v-model="userForm.class"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
:placeholder="locale.form.classPlaceholder"
type="text"
>
</div>
</div>
</div>
<div
v-if="formError"
class="p-4 bg-error-10 border border-error-20 rounded-2xl flex items-center gap-3 text-error text-xs"
>
<AlertCircle :size="16" />
{{ formError }}
</div>
</div>
<div class="flex gap-3 mt-10">
<button
class="flex-1 px-6 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeModal"
>
{{ locale.form.cancel }}
</button>
<button
:disabled="saving"
class="flex-[2] px-6 py-4 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-[var(--primary-glow)] active:scale-95"
@click="saveUser"
>
<Save v-if="!saving" :size="16" />
<RefreshCw v-else class="animate-spin" :size="16" />
{{ saving ? locale.form.saving : editingUser ? locale.form.update : locale.form.create }}
</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-bg-primary-80 backdrop-blur-sm"
>
<div
class="bg-bg-secondary border border-border-secondary 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-warning-10 rounded-[2rem] flex items-center justify-center text-warning mx-auto mb-6"
>
<Lock :size="32" />
</div>
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.resetPasswordModal.title }}</h3>
<p class="text-xs text-text-tertiary mt-2 mb-8">
{{ locale.resetPasswordModal.descPrefix }}
<span class="text-text-primary font-bold">{{ resetPasswordUser.name }}</span>
{{ locale.resetPasswordModal.descSuffix }}
</p>
<div class="space-y-4 text-left">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.resetPasswordModal.newPassword }}</label
>
<div class="relative group">
<Key
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-warning transition-colors"
:size="16"
/>
<input
v-model="passwordForm.password"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-warning-30 transition-all text-text-primary"
:placeholder="locale.resetPasswordModal.newPasswordPlaceholder"
type="password"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.resetPasswordModal.confirmPassword }}</label
>
<div class="relative group">
<Key
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-warning transition-colors"
:size="16"
/>
<input
v-model="passwordForm.confirmPassword"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-warning-30 transition-all text-text-primary"
:placeholder="locale.resetPasswordModal.confirmPasswordPlaceholder"
type="password"
>
</div>
</div>
<div
v-if="passwordError"
class="p-4 bg-error-10 border border-error-20 rounded-2xl flex items-center gap-3 text-error text-xs"
>
<AlertCircle :size="16" />
{{ passwordError }}
</div>
</div>
<div class="flex gap-3 mt-8">
<button
class="flex-1 px-6 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeResetPassword"
>
{{ locale.resetPasswordModal.cancel }}
</button>
<button
:disabled="resetting"
class="flex-[2] px-6 py-4 bg-warning hover:bg-warning text-text-primary text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-[var(--warning-glow-20)] active:scale-95"
@click="confirmResetPassword"
>
<Save v-if="!resetting" :size="16" />
<RefreshCw v-else class="animate-spin" :size="16" />
{{ resetting ? locale.resetPasswordModal.resetting : locale.resetPasswordModal.confirm }}
</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-bg-primary-80 backdrop-blur-sm"
>
<div
class="bg-bg-secondary border border-border-secondary 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-text-primary tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-success-10 flex items-center justify-center text-success"
>
<FileSpreadsheet :size="20" />
</div>
{{ locale.importModal.title }}
</h3>
<p class="text-xs text-text-tertiary mt-1 ml-13">
{{ locale.importModal.desc }}
</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-bg-primary-50 border border-border-secondary-50 rounded-3xl space-y-4">
<div class="flex items-center gap-2 text-text-secondary font-bold text-sm mb-2">
<Info :size="16" class="text-primary" />
{{ locale.importModal.instructionsTitle }}
</div>
<p class="text-xs text-text-tertiary leading-relaxed">
{{ locale.importModal.instructionsLine1 }}
{{ locale.importModal.instructionsLine2 }}
</p>
<div class="overflow-hidden rounded-2xl border border-border-secondary-80">
<table class="w-full text-[10px] text-left">
<thead class="bg-bg-secondary text-text-tertiary uppercase tracking-tighter">
<tr>
<th class="px-3 py-2 border-b border-border-secondary">{{ locale.importModal.sampleHeaders.name }}</th>
<th class="px-3 py-2 border-b border-border-secondary">{{ locale.importModal.sampleHeaders.username }}</th>
<th class="px-3 py-2 border-b border-border-secondary">{{ locale.importModal.sampleHeaders.password }}</th>
<th class="px-3 py-2 border-b border-border-secondary">{{ locale.importModal.sampleHeaders.role }}</th>
<th class="px-3 py-2 border-b border-border-secondary">{{ locale.importModal.sampleHeaders.grade }}</th>
<th class="px-3 py-2 border-b border-border-secondary">{{ locale.importModal.sampleHeaders.class }}</th>
</tr>
</thead>
<tbody class="text-text-tertiary">
<tr class="border-b border-border-secondary-50">
<td class="px-3 py-2">{{ locale.importModal.sampleName }}</td>
<td class="px-3 py-2">zhangsan</td>
<td class="px-3 py-2">******</td>
<td class="px-3 py-2">USER</td>
<td class="px-3 py-2">{{ locale.importModal.sampleGrade }}</td>
<td class="px-3 py-2">{{ locale.importModal.sampleClass }}</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-between gap-4">
<div
class="p-3 bg-primary-5 border border-primary-10 rounded-2xl text-[10px] text-primary-80 leading-relaxed flex-1"
>
<strong>{{ locale.importModal.supportedRoles }}</strong>
<span v-if="isSuperAdmin"
>{{ locale.importModal.supportedRolesFull }}</span
>
<span v-else>{{ locale.importModal.supportedRolesLimited }}</span>
</div>
<button
class="px-4 py-3 bg-success-10 border border-success-20 hover:border-success-40 rounded-xl transition-all flex items-center gap-2 group shrink-0"
@click="downloadImportTemplate"
>
<Download
:size="16"
class="text-success group-hover:scale-110 transition-transform"
/>
<span class="text-[10px] font-black text-success uppercase tracking-widest"
>{{ locale.importModal.downloadTemplate }}</span
>
</button>
</div>
</div>
<div class="space-y-3">
<label class="block text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.importModal.chooseFile }}</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-border-secondary group-hover:border-success-50 group-hover:bg-success-5 rounded-3xl transition-all flex flex-col items-center justify-center gap-3"
>
<div
class="w-12 h-12 rounded-2xl bg-bg-secondary flex items-center justify-center text-text-disabled group-hover:text-success transition-colors"
>
<Upload :size="24" />
</div>
<div class="text-center">
<p class="text-sm font-bold text-text-secondary">{{ locale.importModal.uploadTitle }}</p>
<p class="text-xs text-text-tertiary mt-1">{{ locale.importModal.uploadHint }}</p>
</div>
</div>
</div>
</div>
<div
v-if="importError"
class="p-4 bg-error-10 border border-error-20 rounded-2xl flex gap-3 text-error text-xs items-start"
>
<AlertCircle :size="16" class="mt-0.5 shrink-0" />
<div class="whitespace-pre-wrap leading-relaxed">{{ importError }}</div>
</div>
<!-- 进度条 -->
<div
v-if="importProgressText"
class="p-5 bg-success-5 border border-success-20 rounded-2xl space-y-3 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<div class="flex items-center justify-between">
<span class="text-xs font-black text-success uppercase tracking-widest">{{ importProgressText }}</span>
<span class="text-xs font-black text-success">{{ importProgress }}%</span>
</div>
<div class="h-2 bg-bg-secondary rounded-full overflow-hidden">
<div
class="h-full bg-gradient-to-r from-success to-success-light transition-all duration-300 ease-out rounded-full"
:style="{ width: importProgress + '%' }"
/>
</div>
</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-text-tertiary uppercase tracking-widest"
>{{ formatMessage(locale.importModal.previewData, previewData.length) }}</label
>
</div>
<div class="overflow-hidden rounded-3xl border border-border-secondary bg-bg-primary-30">
<table class="w-full text-xs text-left">
<thead class="bg-bg-secondary-50 text-text-tertiary">
<tr>
<th class="px-4 py-3 font-medium">{{ locale.importModal.sampleHeaders.name }}</th>
<th class="px-4 py-3 font-medium">{{ locale.importModal.account }}</th>
<th class="px-4 py-3 font-medium">{{ locale.importModal.sampleHeaders.role }}</th>
<th class="px-4 py-3 font-medium">{{ locale.importModal.gradeClass }}</th>
</tr>
</thead>
<tbody class="divide-y divide-panel-bg-deepest">
<tr
v-for="(row, index) in previewData.slice(0, 5)"
:key="index"
class="text-text-secondary"
>
<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-bg-tertiary rounded-md text-[10px] text-text-tertiary uppercase"
>{{ row.role }}</span
>
</td>
<td class="px-4 py-3 text-text-tertiary">
{{ row.grade || '-' }} / {{ row.class || '-' }}
</td>
</tr>
</tbody>
</table>
<div
v-if="previewData.length > 5"
class="p-3 text-center border-t border-border-secondary text-[10px] text-text-tertiary font-medium"
>
{{ formatMessage(locale.importModal.moreRecords, previewData.length - 5) }}
</div>
</div>
</div>
</div>
<div class="flex gap-3 mt-8">
<button
class="flex-1 px-6 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeImportModal"
>
{{ locale.importModal.cancel }}
</button>
<button
:disabled="importLoading || previewData.length === 0"
class="flex-[2] px-6 py-4 bg-success hover:bg-success text-text-primary text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-[var(--success-glow-20)] active:scale-95"
@click="importUsers"
>
<Save v-if="!importLoading" :size="16" />
<RefreshCw v-else class="animate-spin" :size="16" />
{{ importLoading ? locale.importModal.importing : locale.importModal.confirm }}
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- 删除确认对话框 -->
<ConfirmDialog
:show="showDeleteModal"
:title="locale.deleteDialog.title"
:message="formatMessage(locale.deleteDialog.message, deletingUser?.name || '')"
type="danger"
:confirm-text="locale.deleteDialog.confirm"
:loading="deleting"
@confirm="confirmDelete"
@close="closeDeleteModal"
/>
<!-- 批量更新模态框 -->
<BatchUpdateModal
:show="showBatchUpdateModal"
:users="users"
@close="closeBatchUpdateModal"
@update-success="handleBatchUpdateSuccess"
/>
<!-- 用户歌曲模态框 -->
<UserSongsModal
:show="showUserSongsModal"
:user-id="selectedUserId"
@close="closeUserSongsModal"
/>
<!-- 用户注册审核模态框 -->
<UserApprovalModal
:show="showApprovalModal"
:user="userToApprove"
@close="showApprovalModal = false"
@success="handleApprovalSuccess"
/>
<!-- OAuth 绑定详情模态框 -->
<OAuthBindingsModal
:show="showOAuthBindingsModal"
:identities="selectedUserDetail?.identities || []"
@close="closeOAuthBindingsModal"
/>
<!-- 用户详细信息模态框 -->
<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-bg-primary-80 backdrop-blur-sm"
@click="closeUserDetailModal"
>
<div
class="bg-bg-secondary border border-border-secondary 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-text-primary tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
>
<User :size="20" />
</div>
{{ locale.detail.title }}
</h3>
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.detail.desc }}</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary 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-text-tertiary uppercase tracking-widest ml-1"
>
<Info :size="14" class="text-primary" />
{{ locale.detail.basicInfo }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.userId }}
</div>
<div class="text-sm font-bold text-text-secondary">{{ selectedUserDetail.id }}</div>
</div>
<div class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.name }}
</div>
<div class="text-sm font-bold text-text-secondary">{{ selectedUserDetail.name }}</div>
</div>
<div class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.username }}
</div>
<div class="text-sm font-bold text-text-secondary">
{{ selectedUserDetail.username }}
</div>
</div>
<div class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.role }}
</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-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.grade }}
</div>
<div class="text-sm font-bold text-text-secondary">
{{ selectedUserDetail.grade || locale.detail.unset }}
</div>
</div>
<div class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.class }}
</div>
<div class="text-sm font-bold text-text-secondary">
{{ selectedUserDetail.class || locale.detail.unset }}
</div>
</div>
</div>
</div>
<!-- 账户状态 -->
<div class="space-y-4">
<div
class="flex items-center gap-2 text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
>
<Shield :size="14" class="text-success" />
{{ locale.detail.status }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl flex items-center justify-between"
>
<div class="space-y-1 overflow-hidden pr-2">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.passwordStatus }}
</div>
<div
class="text-sm font-bold truncate"
:class="
!selectedUserDetail.forcePasswordChange &&
selectedUserDetail.passwordChangedAt
? 'text-success'
: 'text-warning'
"
>
{{
!selectedUserDetail.forcePasswordChange &&
selectedUserDetail.passwordChangedAt
? locale.detail.passwordChanged
: locale.detail.requirePasswordChange
}}
</div>
</div>
<div
:class="[
'w-8 h-8 rounded-xl flex items-center justify-center shrink-0',
!selectedUserDetail.forcePasswordChange &&
selectedUserDetail.passwordChangedAt
? 'bg-success-10 text-success'
: 'bg-warning-10 text-warning'
]"
>
<CheckCircle2
v-if="
!selectedUserDetail.forcePasswordChange &&
selectedUserDetail.passwordChangedAt
"
:size="16"
/>
<AlertCircle v-else :size="16" />
</div>
</div>
<div
class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl flex items-center justify-between"
>
<div class="space-y-1 overflow-hidden pr-2">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.meowBinding }}
</div>
<div
class="text-sm font-bold truncate"
:class="
selectedUserDetail.meowNickname ? 'text-success' : 'text-text-tertiary'
"
:title="selectedUserDetail.meowNickname ? formatMessage(locale.detail.bound, selectedUserDetail.meowNickname) : locale.detail.unbound"
>
{{
selectedUserDetail.meowNickname
? formatMessage(locale.detail.bound, selectedUserDetail.meowNickname)
: locale.detail.unbound
}}
</div>
</div>
<div
:class="[
'w-8 h-8 rounded-xl flex items-center justify-center shrink-0',
selectedUserDetail.meowNickname
? 'bg-success-10 text-success'
: 'bg-bg-tertiary text-text-disabled'
]"
>
<AtSign :size="16" />
</div>
</div>
<!-- 邮箱绑定 -->
<div
class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl flex items-center justify-between"
>
<div class="space-y-1 overflow-hidden pr-2">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.emailBinding }}
</div>
<div
class="text-sm font-bold truncate"
:class="
selectedUserDetail.email ? (selectedUserDetail.emailVerified ? 'text-success' : 'text-warning') : 'text-text-tertiary'
"
:title="selectedUserDetail.email ? `${selectedUserDetail.email} (${selectedUserDetail.emailVerified ? locale.detail.verified : locale.detail.unverified})` : locale.detail.unbound"
>
{{
selectedUserDetail.email
? `${selectedUserDetail.email} (${selectedUserDetail.emailVerified ? locale.detail.verified : locale.detail.unverified})`
: locale.detail.unbound
}}
</div>
</div>
<div
:class="[
'w-8 h-8 rounded-xl flex items-center justify-center shrink-0',
selectedUserDetail.email
? (selectedUserDetail.emailVerified ? 'bg-success-10 text-success' : 'bg-warning-10 text-warning')
: 'bg-bg-tertiary text-text-disabled'
]"
>
<Mail :size="16" />
</div>
</div>
<!-- OAuth 账号绑定 -->
<button
type="button"
class="p-4 bg-bg-primary-50 border border-border-secondary-50 rounded-2xl flex items-center justify-between w-full text-left transition-all disabled:opacity-60 disabled:cursor-not-allowed"
:class="
selectedUserDetail.identities?.length > 0
? 'cursor-pointer hover:bg-bg-primary hover:border-border-tertiary group'
: ''
"
:disabled="!selectedUserDetail.identities?.length"
@click="openOAuthBindingsModal"
>
<div class="space-y-1 overflow-hidden pr-2">
<div class="text-[10px] font-black text-text-disabled uppercase tracking-tighter">
{{ locale.detail.oauthBinding }}
</div>
<div
class="text-sm font-bold truncate capitalize"
:class="
selectedUserDetail.identities?.length > 0 ? 'text-success' : 'text-text-tertiary'
"
:title="selectedUserDetail.identities?.length > 0 ? formatMessage(locale.detail.bound, selectedUserDetail.identities.map(id => getOAuthProviderName(id.provider)).join(', ')) : locale.detail.unbound"
>
{{
selectedUserDetail.identities?.length > 0
? formatMessage(locale.detail.bound, selectedUserDetail.identities.map(id => getOAuthProviderName(id.provider)).join(', '))
: locale.detail.unbound
}}
</div>
</div>
<div class="flex items-center gap-1.5 shrink-0">
<div
:class="[
'w-8 h-8 rounded-xl flex items-center justify-center',
selectedUserDetail.identities?.length > 0
? 'bg-success-10 text-success'
: 'bg-bg-tertiary text-text-disabled'
]"
>
<Link :size="16" />
</div>
<ChevronRight
v-if="selectedUserDetail.identities?.length > 0"
:size="16"
class="text-text-disabled transition-transform group-hover:translate-x-0.5"
/>
</div>
</button>
</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-text-tertiary uppercase tracking-widest ml-1"
>
<Clock :size="14" class="text-info" />
{{ locale.detail.loginInfo }}
</div>
<div class="space-y-3">
<div
class="flex items-center justify-between text-xs p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30"
>
<span class="text-text-tertiary">{{ locale.detail.lastLogin }}</span>
<span class="text-text-secondary font-medium">{{
formatDate(selectedUserDetail.lastLogin)
}}</span>
</div>
<div
class="flex items-center justify-between text-xs p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30"
>
<span class="text-text-tertiary">{{ locale.detail.loginIp }}</span>
<span class="text-text-secondary font-medium">{{
selectedUserDetail.lastLoginIp || locale.detail.unknown
}}</span>
</div>
</div>
</div>
<div class="space-y-4">
<div
class="flex items-center gap-2 text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
>
<Calendar :size="14" class="text-warning" />
{{ locale.detail.timeRecords }}
</div>
<div class="space-y-3">
<div
class="flex items-center justify-between text-xs p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30"
>
<span class="text-text-tertiary">{{ locale.detail.createdAt }}</span>
<span class="text-text-secondary font-medium">{{
formatDate(selectedUserDetail.createdAt)
}}</span>
</div>
<div
class="flex items-center justify-between text-xs p-3 bg-bg-primary-30 rounded-xl border border-border-secondary-30"
>
<span class="text-text-tertiary">{{ locale.detail.updatedAt }}</span>
<span class="text-text-secondary 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-text-tertiary uppercase tracking-widest"
>
<History :size="14" class="text-success" />
{{ locale.detail.statusLogs }}
</div>
</div>
<div
v-if="statusLogsLoading"
class="py-12 flex flex-col items-center justify-center text-text-disabled gap-3"
>
<RefreshCw :size="24" class="animate-spin" />
<span class="text-[10px] font-black uppercase tracking-widest"
>{{ locale.detail.loadingLogs }}</span
>
</div>
<div
v-else-if="statusLogs.length === 0"
class="py-12 text-center bg-bg-primary-30 border border-border-secondary-50 rounded-3xl"
>
<p class="text-xs text-text-disabled">{{ locale.detail.noLogs }}</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-bg-tertiary"
>
<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-bg-secondary border-2 border-border-tertiary ring-4 ring-panel-bg-deepest"
/>
<div
class="p-4 bg-bg-primary-50 border border-border-secondary-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 || locale.detail.initial }}
</span>
<ArrowRight :size="12" class="text-text-secondary" />
<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-text-disabled font-medium">{{
formatStatusLogDate(log.createdAt)
}}</span>
</div>
<div
v-if="log.reason"
class="text-xs text-text-tertiary bg-bg-secondary-50 p-2.5 rounded-xl border border-border-secondary-30 leading-relaxed"
>
<span class="text-text-disabled font-bold mr-1">{{ locale.detail.reason }}</span>
{{ log.reason }}
</div>
<div class="flex items-center gap-2 text-[10px]">
<div
class="w-4 h-4 rounded-full bg-bg-tertiary flex items-center justify-center text-text-tertiary"
>
<User :size="8" />
</div>
<span class="text-text-tertiary">{{ locale.detail.operator }}</span>
<span class="text-text-secondary font-bold">{{
log.operator?.name || locale.detail.system
}}</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-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeUserDetailModal"
>
{{ locale.actions.close }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue'
import { useAuth } from '~/composables/useAuth'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
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,
Filter,
Download,
Upload,
UserCog,
ShieldAlert,
Calendar,
Clock3,
Hash,
AtSign,
Briefcase,
Link,
ClipboardCheck
} 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 OAuthBindingsModal from '~/components/Admin/OAuthBindingsModal.vue'
import BatchUpdateModal from '~/components/Admin/BatchUpdateModal.vue'
import UserApprovalModal from '~/components/Admin/UserApprovalModal.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
import { getOAuthProviderName, getAggregateOAuthLoginTypeName, getProviderDisplayName } from '~/utils/oauth'
import { GRADE_ORDER } from '~/utils/gradeClassWeights'
import { isArchivedStatus } from '~/utils/user-archive'
const { admin, currentLocale } = useLocale()
const locale = computed(() => admin.value?.userManager || {})
const formatMessage = (value, ...args) => {
if (typeof value === 'function') return value(...args)
if (typeof value === 'string') {
return value.replace(/{(\d+)}/g, (match, index) =>
args[index] !== undefined ? String(args[index]) : match
)
}
return ''
}
const getRoleName = (role) => {
const aliases = { USER: 'user', SONG_ADMIN: 'songAdmin', ADMIN: 'admin', SUPER_ADMIN: 'superAdmin' }
return locale.value?.roles?.[aliases[role]] || role
}
const getStatusName = (status) => locale.value?.statuses?.[status] || status
const getErrorDetail = (error) =>
error?.data?.message || error?.message || error?.statusMessage || locale.value?.detail?.unknown || '未知错误'
// 响应式数据
const loading = ref(false)
const users = ref([])
const showApprovalModal = ref(false)
const userToApprove = ref(null)
const searchQuery = ref('')
const roleFilter = ref('')
const statusFilter = ref('')
const gradeFilter = ref('')
const classFilter = ref('')
// 归档视图:graduate/withdrawn 状态账号不在默认列表直接展示,经组织树底部归档节点进入
const showArchived = ref(false)
const archivedCount = ref(0)
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 treeUsers = ref([])
const treeLoading = ref(false)
const treeError = ref('')
const expandedTreeNodes = ref(new Set())
const treeFilterLabel = ref('')
const UNSET_FILTER_VALUE = '__UNSET__'
const unsetGradeLabel = computed(() => locale.value?.organization?.unsetGrade || '未设置年级')
const unsetClassLabel = computed(() => locale.value?.organization?.unsetClass || '未设置班级')
const sortOptions = computed(() => [
{ label: locale.value?.sortOptions?.default || '默认排序', value: 'id-asc' },
{ label: locale.value?.sortOptions?.nameAsc || '姓名升序', value: 'name-asc' },
{ label: locale.value?.sortOptions?.nameDesc || '姓名降序', value: 'name-desc' },
{ label: locale.value?.sortOptions?.lastLoginDesc || '最近登录优先', value: 'lastLogin-desc' },
{ label: locale.value?.sortOptions?.lastLoginAsc || '最早登录优先', value: 'lastLogin-asc' },
{ label: locale.value?.sortOptions?.createdAtDesc || '最新创建优先', value: 'createdAt-desc' },
{ label: locale.value?.sortOptions?.createdAtAsc || '最早创建优先', 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(() => [
{ name: 'USER', displayName: getRoleName('USER') },
{ name: 'SONG_ADMIN', displayName: getRoleName('SONG_ADMIN') },
{ name: 'ADMIN', displayName: getRoleName('ADMIN') },
{ name: 'SUPER_ADMIN', displayName: getRoleName('SUPER_ADMIN') }
])
// 筛选选项
const roleFilterOptions = computed(() => [{ name: '', displayName: locale.value?.allRoles || '全部角色' }, ...allRoles.value])
// 状态筛选:普通视图不提供 graduate/withdrawn 选项(已归档账号默认隐藏);归档视图仅这两类
const statusFilterOptions = computed(() => {
const options = [{ label: locale.value?.allStatus || '全部状态', value: '' }]
if (showArchived.value) {
options.push(
{ label: getStatusName('graduate'), value: 'graduate' },
{ label: getStatusName('withdrawn'), value: 'withdrawn' }
)
} else {
options.push(
{ label: getStatusName('active'), value: 'active' },
{ label: getStatusName('pending'), value: 'pending' }
)
}
return options
})
const userStatusOptions = computed(() => [
{ label: getStatusName('activeAccess'), value: 'active' },
{ label: getStatusName('withdrawnAccess'), value: 'withdrawn' },
{ label: getStatusName('graduateAccess'), value: 'graduate' }
])
// 模态框状态
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 previewData = ref([])
const xlsxLoaded = ref(false)
const importStartRow = ref(0)
const importProgress = ref(0)
const importProgressText = ref('')
// 批量更新状态
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)
// OAuth 绑定详情模态框状态
const showOAuthBindingsModal = ref(false)
// 状态变更日志模态框状态
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 { localize } = useServerErrors()
// 判断是否为当前登录用户
const isSelf = (user) => {
return auth.user.value?.id === user?.id
}
// 计算属性
const isSuperAdmin = computed(() => {
return auth.user.value?.role === 'SUPER_ADMIN'
})
// 普通管理员身份
const isAdmin = computed(() => {
return auth.user.value?.role === 'ADMIN'
})
// 是否允许对目标用户执行管理操作(编辑/重置密码/删除),与后端越级保护一致
const canManageUser = (user) => {
if (!user || isSelf(user)) return false
// 普通管理员不能操作超级管理员
return isSuperAdmin.value || user.role !== 'SUPER_ADMIN'
}
const availableRoles = computed(() => {
let roles
if (isSuperAdmin.value) {
// 超级管理员可以分配除自己以外的所有角色
roles = allRoles.value.filter((role) => role.name !== 'SUPER_ADMIN')
} else if (isAdmin.value) {
// 管理员只能分配用户和歌曲管理员角色(与后端用户接口的角色限制一致)
roles = allRoles.value.filter((role) => ['USER', 'SONG_ADMIN'].includes(role.name))
} else {
// 其他角色不能分配角色,返回空数组
return []
}
// 保留目标用户当前角色,避免当前值不在选项中时下拉显示裸值
const currentRole = editingUser.value?.role
if (currentRole && !roles.some((role) => role.name === currentRole)) {
roles = [...roles, ...allRoles.value.filter((role) => role.name === currentRole)]
}
return roles
})
const getUserDisplayName = (user) => {
return user?.name || user?.username || locale.value?.organization?.unnamedUser || '未命名用户'
}
const getUserInitial = (user) => {
return getUserDisplayName(user).charAt(0)
}
const normalizeTreeValue = (value, fallback) => {
const normalized = value?.toString().trim()
return normalized || fallback
}
const getStageLabel = (user) => {
const stages = locale.value?.organization?.stages || {}
if (user.status === 'graduate') return stages.graduate || ''
if (user.status === 'withdrawn') return stages.withdrawn || ''
const grade = normalizeTreeValue(user.grade, unsetGradeLabel.value)
if (grade.startsWith('高')) return stages.senior || ''
if (grade.startsWith('初')) return stages.junior || ''
if (grade.startsWith('大')) return stages.university || ''
if (['教师', '教职工'].includes(grade)) return stages.staff || ''
return stages.other || ''
}
const gradeSortWeight = (grade) => {
const order = {
...GRADE_ORDER,
[unsetGradeLabel.value]: 100
}
return order[grade] ?? 50
}
const sortTreeLabels = (a, b) => {
const weightA = gradeSortWeight(a.label)
const weightB = gradeSortWeight(b.label)
if (weightA !== weightB) return weightA - weightB
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true })
}
const sortByLabel = (a, b) => {
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true })
}
const toUserFilterQuery = (value, unsetLabel) => {
if (!value) return undefined
return value === unsetLabel ? UNSET_FILTER_VALUE : value
}
// 按年级/班级将用户归组为树节点(学段与归档目录共用)
const buildGradeNodes = (users, stageKey) => {
const gradeMap = new Map()
for (const user of users) {
const gradeLabel = normalizeTreeValue(user.grade, unsetGradeLabel.value)
const classLabel = normalizeTreeValue(user.class, unsetClassLabel.value)
const gradeKey = `${stageKey}:grade:${gradeLabel}`
const classKey = `${gradeKey}:class:${classLabel}`
if (!gradeMap.has(gradeKey)) {
gradeMap.set(gradeKey, {
key: gradeKey,
label: gradeLabel,
count: 0,
classes: new Map()
})
}
const grade = gradeMap.get(gradeKey)
grade.count += 1
if (!grade.classes.has(classKey)) {
grade.classes.set(classKey, {
key: classKey,
label: classLabel,
count: 0,
users: []
})
}
const classNode = grade.classes.get(classKey)
classNode.count += 1
classNode.users.push(user)
}
return Array.from(gradeMap.values())
.map((grade) => ({
...grade,
classes: Array.from(grade.classes.values())
.map((classNode) => ({
...classNode,
users: classNode.users.sort((a, b) =>
getUserDisplayName(a).localeCompare(getUserDisplayName(b), 'zh-CN', {
numeric: true
})
)
}))
.sort(sortByLabel)
}))
.sort(sortTreeLabels)
}
const buildStageNodes = (users) => {
const stageMap = new Map()
for (const user of users) {
const stageLabel = getStageLabel(user)
const stageKey = `stage:${stageLabel}`
if (!stageMap.has(stageKey)) {
stageMap.set(stageKey, {
key: stageKey,
label: stageLabel,
count: 0,
users: []
})
}
const stage = stageMap.get(stageKey)
stage.count += 1
stage.users.push(user)
}
return Array.from(stageMap.values())
.map((stage) => ({
key: stage.key,
label: stage.label,
count: stage.count,
grades: buildGradeNodes(stage.users, stage.key)
}))
.sort((a, b) => {
const stages = locale.value?.organization?.stages || {}
const stageOrder = [stages.junior, stages.senior, stages.university, stages.staff, stages.other]
const indexA = stageOrder.indexOf(a.label)
const indexB = stageOrder.indexOf(b.label)
const weightA = indexA === -1 ? 50 : indexA
const weightB = indexB === -1 ? 50 : indexB
if (weightA !== weightB) return weightA - weightB
return a.label.localeCompare(b.label, 'zh-CN', { numeric: true })
})
}
const archiveStageLabel = computed(() => locale.value?.archivedUsers || '已归档用户')
const userTree = computed(() => {
const stages = buildStageNodes(treeUsers.value.filter((user) => !isArchivedStatus(user.status)))
const archivedUsers = treeUsers.value.filter((user) => isArchivedStatus(user.status))
const stageKey = `stage:${archiveStageLabel.value}`
// 归档目录固定挂在树末尾,不在学段中直接展示;点击节点名进入归档视图
stages.push({
key: stageKey,
label: archiveStageLabel.value,
count: archivedUsers.length,
grades: buildGradeNodes(archivedUsers, stageKey)
})
return stages
})
const activeOrgFilterLabel = computed(() => {
const scopeParts = []
if (treeFilterLabel.value) {
scopeParts.push(treeFilterLabel.value)
}
if (gradeFilter.value) {
scopeParts.push(gradeFilter.value)
}
if (gradeFilter.value && classFilter.value) {
scopeParts.push(classFilter.value)
}
return scopeParts.join(' / ')
})
let loadUsersDebounceTimer = null
// 视图切换重置筛选后由切换逻辑统一重载列表,置位抑制监听器的重复加载
let suppressNextFilterReload = false
// 监听搜索和过滤条件变化
watch(
[searchQuery, roleFilter, statusFilter, gradeFilter, classFilter, sortBy, sortOrder],
() => {
if (loadUsersDebounceTimer) {
clearTimeout(loadUsersDebounceTimer)
}
if (suppressNextFilterReload) {
suppressNextFilterReload = false
return
}
loadUsersDebounceTimer = setTimeout(() => {
if (currentPage.value !== 1) {
currentPage.value = 1
} else {
loadUsers(1, pageSize.value)
}
}, 300)
}
)
// 监听页码变化
watch(currentPage, (newPage) => {
loadUsers(newPage, pageSize.value)
})
// 方法
const formatDate = (dateString) => {
const timeLocale = locale.value?.time || {}
if (!dateString) return timeLocale.never || ''
const date = new Date(dateString)
const now = getSyncedDate()
const diff = now - date
if (diff < 60000) return timeLocale.justNow || ''
if (diff < 3600000) return formatMessage(timeLocale.minutesAgo, Math.floor(diff / 60000))
if (diff < 86400000) return formatMessage(timeLocale.hoursAgo, Math.floor(diff / 3600000))
if (diff < 86400000 * 7) return formatMessage(timeLocale.daysAgo, Math.floor(diff / 86400000))
return date.toLocaleDateString(currentLocale.value)
}
const getRoleClass = (role) => {
const classes = {
USER: 'user',
ADMIN: 'admin',
SONG_ADMIN: 'song-admin',
SUPER_ADMIN: 'super-admin'
}
return classes[role] || 'user'
}
const getRoleDisplayName = (role) => {
return getRoleName(role)
}
const getStatusClass = (status) => {
const classes = {
active: 'status-active',
withdrawn: 'status-withdrawn',
graduate: 'status-graduate'
}
return classes[status] || 'status-active'
}
const getStatusDisplayName = (status) => {
return getStatusName(status) || getStatusName('active')
}
const editUser = (user) => {
// 禁止编辑自身
if (isSelf(user)) {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.selfEditForbidden, '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 openApproval = (user) => {
userToApprove.value = user
showApprovalModal.value = true
}
const handleApprovalSuccess = (result) => {
void loadUsers(currentPage.value, pageSize.value)
if (window.$showNotification) {
const message =
result?.action === 'approve'
? locale.value.approval?.approveSuccess
: locale.value.approval?.rejectSuccess
window.$showNotification(message, 'success')
}
}
const resetPassword = (user) => {
// 禁止重置自身密码
if (isSelf(user)) {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.selfResetForbidden, 'warning')
}
return
}
resetPasswordUser.value = user
passwordForm.value = {
password: '',
confirmPassword: ''
}
}
const confirmDeleteUser = (user) => {
// 禁止删除自身
if (isSelf(user)) {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.selfDeleteForbidden, '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
showOAuthBindingsModal.value = false
}
const openOAuthBindingsModal = () => {
if (!selectedUserDetail.value?.identities?.length) return
showOAuthBindingsModal.value = true
}
const closeOAuthBindingsModal = () => {
showOAuthBindingsModal.value = false
}
const closeBatchUpdateModal = () => {
showBatchUpdateModal.value = false
}
const handleBatchUpdateSuccess = async () => {
await Promise.all([loadUserTree(), loadUsers()])
}
const closeDeleteModal = () => {
showDeleteModal.value = false
deletingUser.value = null
deleting.value = false
}
const confirmDelete = async () => {
if (!deletingUser.value) return
deleting.value = true
try {
await $fetch(`/api/admin/users/${deletingUser.value.id}`, {
method: 'DELETE',
...auth.getAuthConfig()
})
await Promise.all([loadUserTree(), loadUsers()])
closeDeleteModal()
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.deleteSuccess, 'success')
}
} catch (error) {
console.error('删除用户失败:', error)
if (window.$showNotification) {
window.$showNotification(formatMessage(locale.value.notifications.deleteFailed, getErrorDetail(error)), '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 isTreeNodeExpanded = (key) => {
return expandedTreeNodes.value.has(key)
}
const toggleTreeNode = (key) => {
const next = new Set(expandedTreeNodes.value)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
expandedTreeNodes.value = next
}
const expandDefaultTreeNodes = () => {
const next = new Set(expandedTreeNodes.value)
userTree.value.forEach((stage) => {
// 归档节点默认折叠,点击节点名进入归档视图,手动展开查看目录
if (stage.label === archiveStageLabel.value) return
next.add(stage.key)
stage.grades.forEach((grade) => {
next.add(grade.key)
})
})
expandedTreeNodes.value = next
}
const isTreeFilterActive = (stageLabel, grade, className) => {
return (
treeFilterLabel.value === stageLabel &&
gradeFilter.value === grade &&
classFilter.value === className
)
}
const isStageFilterActive = (stageLabel) => {
return treeFilterLabel.value === stageLabel && !gradeFilter.value && !classFilter.value
}
const handleStageClick = (stage) => {
// 归档节点点击进入归档视图,其余学段仅展开/折叠
if (stage.label === archiveStageLabel.value) {
applyUserScope(true, { stageLabel: stage.label })
return
}
toggleTreeNode(stage.key)
}
const applyTreeFilter = (grade, className = '', stageLabel = '') => {
// 归档目录下的年级/班级点击进入归档视图并定位范围
applyUserScope(stageLabel === archiveStageLabel.value, { grade, className, stageLabel })
}
const clearTreeFilter = () => {
if (showArchived.value) {
applyUserScope(false, { stageLabel: '' })
return
}
gradeFilter.value = ''
classFilter.value = ''
if (treeFilterLabel.value) {
statusFilter.value = ''
}
treeFilterLabel.value = ''
}
// 统一设置用户视图与树范围:重置筛选与分页,并保证列表恰好重载一次
const applyUserScope = (archived, { grade = '', className = '', stageLabel = '' } = {}) => {
const viewChanged = showArchived.value !== archived
const filtersChanged =
searchQuery.value !== '' ||
gradeFilter.value !== grade ||
classFilter.value !== className ||
(viewChanged && (roleFilter.value !== '' || statusFilter.value !== ''))
const scopeChanged = treeFilterLabel.value !== stageLabel
if (!viewChanged && !filtersChanged && !scopeChanged && currentPage.value === 1) {
return
}
// 有筛选值将被重置时筛选监听器必触发一次,置位抑制避免重复请求
if (filtersChanged) {
if (loadUsersDebounceTimer) {
clearTimeout(loadUsersDebounceTimer)
loadUsersDebounceTimer = null
}
suppressNextFilterReload = true
}
// 页码保持 1 时页码监听器不触发,需显式重载;否则交给页码监听器
const reloadNow = currentPage.value === 1
showArchived.value = archived
searchQuery.value = ''
if (viewChanged) {
// 两个视图的状态筛选选项不同,切换视图时一并重置
roleFilter.value = ''
statusFilter.value = ''
}
gradeFilter.value = grade
classFilter.value = className
treeFilterLabel.value = stageLabel
currentPage.value = 1
if (reloadNow) {
void loadUsers(1, pageSize.value)
}
}
const openTreeUser = async (treeUser) => {
try {
const detail = await $fetch(`/api/admin/users/${treeUser.id}`, {
...auth.getAuthConfig()
})
selectedUserDetail.value = detail
showUserDetailModal.value = true
loadStatusLogsPage(1)
} catch (error) {
console.error('加载用户详情失败:', error)
if (window.$showNotification) {
window.$showNotification(formatMessage(locale.value.notifications.detailLoadFailed, getErrorDetail(error)), 'error')
}
}
}
const saveUser = async () => {
// 保护:禁止保存针对自身的更改
if (editingUser.value && isSelf(editingUser.value)) {
formError.value = locale.value.notifications.selfEditForbidden
return
}
if (!userForm.value.name || !userForm.value.username) {
formError.value = locale.value.errors.requiredInfo
return
}
if (!editingUser.value && !userForm.value.password) {
formError.value = locale.value.errors.passwordRequired
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
}
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()
})
}
// 先缓存是否编辑态:closeModal 会清空 editingUser,通知文案需在清空前判定
const wasEditing = Boolean(editingUser.value)
await Promise.all([loadUserTree(), loadUsers()])
closeModal()
if (window.$showNotification) {
window.$showNotification(
wasEditing ? locale.value.notifications.updateSuccess : locale.value.notifications.createSuccess,
'success'
)
}
} catch (error) {
console.error('保存用户失败:', error)
formError.value = localize(error, locale.value.errors.saveFailed)
} finally {
saving.value = false
}
}
const confirmResetPassword = async () => {
// 保护:禁止重置自身密码
if (resetPasswordUser.value && isSelf(resetPasswordUser.value)) {
passwordError.value = locale.value.notifications.selfResetForbidden
return
}
if (!passwordForm.value.password) {
passwordError.value = locale.value.errors.newPasswordRequired
return
}
if (passwordForm.value.password !== passwordForm.value.confirmPassword) {
passwordError.value = locale.value.errors.passwordMismatch
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(locale.value.notifications.resetSuccess, 'success')
}
} catch (error) {
console.error('重置密码失败:', error)
passwordError.value = getErrorDetail(error) || locale.value.errors.resetFailed
} 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,
grade: toUserFilterQuery(gradeFilter.value, unsetGradeLabel.value),
class: toUserFilterQuery(classFilter.value, unsetClassLabel.value),
sortBy: sortBy.value,
sortOrder: sortOrder.value,
archived: showArchived.value ? '1' : '0'
},
...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(formatMessage(locale.value.notifications.loadFailed, getErrorDetail(error)), 'error')
}
} finally {
loading.value = false
}
}
const loadUserTree = async () => {
treeLoading.value = true
treeError.value = ''
try {
const response = await $fetch('/api/admin/users/options', {
...auth.getAuthConfig()
})
treeUsers.value = response.treeUsers || []
archivedCount.value = response.archivedCount || 0
expandDefaultTreeNodes()
} catch (error) {
console.error('加载组织结构失败:', error)
treeError.value = getErrorDetail(error) || locale.value.organization.loadFailed
} finally {
treeLoading.value = false
}
}
// 批量导入相关方法
const closeImportModal = () => {
showImportModal.value = false
importError.value = ''
importProgress.value = 0
importProgressText.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(locale.value.errors.xlsxLoadFailed)
}
} catch (err) {
console.error('加载XLSX库失败:', err)
}
}
}
// 处理文件上传
const handleFileUpload = async (event) => {
const file = event.target.files[0]
if (!file) return
importError.value = ''
importProgressText.value = ''
importProgress.value = 0
previewData.value = []
// 确保XLSX库已加载
if (!window.XLSX) {
await loadXLSX()
if (!window.XLSX) {
importError.value = locale.value.errors.xlsxLoadFailed
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',
...Object.values(locale.value.importModal.sampleHeaders || {})
]
const isHeaderRow = firstRow.some(
(cell) =>
cell &&
titleKeywords.some((keyword) =>
cell.toString().toLowerCase().includes(keyword.toLowerCase())
)
)
if (isHeaderRow) {
startRow = 1
console.log('检测到标题行,从第二行开始解析')
} else {
console.log('未检测到标题行,从第一行开始解析')
}
}
importStartRow.value = startRow
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 和 SONG_ADMIN 角色
if (role !== 'USER' && role !== 'SONG_ADMIN') {
role = 'USER' // 默认设为普通用户
}
}
// 确保角色有效
const validRoles = ['USER', 'ADMIN', 'SONG_ADMIN', 'SUPER_ADMIN']
if (!validRoles.includes(role)) {
role = 'USER'
}
userData.push({
name: row[0]?.toString() || '',
username: row[1]?.toString() || '',
password: row[2]?.toString() || '',
role: role,
grade: row[4]?.toString() || '',
class: row[5]?.toString() || ''
})
}
if (userData.length === 0) {
importError.value = locale.value.errors.noValidData
return
}
previewData.value = userData
} catch (err) {
console.error('解析Excel出错:', err)
importError.value = formatMessage(locale.value.errors.parseExcelFailed, getErrorDetail(err))
}
}
reader.onerror = () => {
importError.value = locale.value.errors.readFileFailed
}
reader.readAsArrayBuffer(file)
} catch (err) {
console.error('处理Excel文件错误:', err)
importError.value = formatMessage(locale.value.errors.processExcelFailed, getErrorDetail(err))
}
}
// 下载导入模板
const downloadImportTemplate = async () => {
if (!window.XLSX) {
await loadXLSX()
if (!window.XLSX) {
if (window.$showNotification) {
window.$showNotification(locale.value.notifications.excelLoadFailed, 'error')
}
return
}
}
const headers = locale.value.importModal.sampleHeaders
const templateData = [
{
[headers.name]: locale.value.importModal.sampleName,
[headers.username]: 'zhangsan',
[headers.password]: '123456',
[headers.role]: 'USER',
[headers.grade]: locale.value.importModal.sampleGrade,
[headers.class]: locale.value.importModal.sampleClass
}
]
const ws = window.XLSX.utils.json_to_sheet(templateData)
const wb = window.XLSX.utils.book_new()
window.XLSX.utils.book_append_sheet(wb, ws, locale.value.importModal.templateSheetName)
window.XLSX.writeFile(wb, locale.value.importModal.templateFileName)
}
// 格式化导入错误的辅助函数
const formatImportErrors = (errors) => {
return errors.map(e => {
const namePart = e.name || e.username ? ` (${e.name || e.username})` : ''
return formatMessage(locale.value.importProgress.rowError, e.rowNum || '?', namePart, e.reason)
}).join('\n')
}
// 批量导入用户
const importUsers = async () => {
if (previewData.value.length === 0) return
importLoading.value = true
importError.value = ''
importProgress.value = 0
importProgressText.value = ''
const dataToImport = [...previewData.value]
const batchSize = 50
const totalBatches = Math.ceil(dataToImport.length / batchSize)
let totalCreated = 0
let totalFailed = 0
const allErrors = []
try {
for (let i = 0; i < dataToImport.length; i += batchSize) {
const batch = dataToImport.slice(i, i + batchSize)
const currentBatch = Math.floor(i / batchSize) + 1
importProgressText.value = formatMessage(locale.value.importProgress.processingBatch, currentBatch, totalBatches)
importProgress.value = Math.round((currentBatch / totalBatches) * 100)
try {
const result = await $fetch('/api/admin/users/batch', {
method: 'POST',
body: {
users: batch
},
...auth.getAuthConfig()
})
totalCreated += result.created || 0
totalFailed += result.failed || 0
if (result.errors && result.errors.length > 0) {
const batchErrors = result.errors.map(e => ({
...e,
rowNum: i + e.index + importStartRow.value + 1 // 正确计算行号
}))
allErrors.push(...batchErrors)
}
} catch (batchErr) {
console.error(`第 ${currentBatch} 批导入失败:`, batchErr)
totalFailed += batch.length
allErrors.push({ reason: formatMessage(locale.value.importProgress.batchFailed, currentBatch, getErrorDetail(batchErr)) })
}
}
await Promise.all([loadUserTree(), loadUsers()])
if (totalCreated > 0 || totalFailed === 0) {
importProgressText.value = formatMessage(locale.value.importProgress.complete, totalCreated, totalFailed)
importProgress.value = 100
if (allErrors.length > 0) {
importError.value = formatMessage(locale.value.errors.partialImportFailed, formatImportErrors(allErrors))
}
previewData.value = []
setTimeout(() => {
if (importProgressText.value && allErrors.length === 0) {
closeImportModal()
}
}, 3000)
} else {
importError.value = formatMessage(locale.value.errors.importFailedWithReasons, formatImportErrors(allErrors))
importProgressText.value = ''
}
} catch (err) {
importError.value = formatMessage(locale.value.errors.importRuntimeFailed, totalCreated, getErrorDetail(err))
importProgressText.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'
},
...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 = getErrorDetail(error) || locale.value.errors.statusLogsFailed
} finally {
statusLogsLoading.value = false
}
}
const formatStatusLogDate = (dateString) => {
if (!dateString) return locale.value.time.unknown
const date = new Date(dateString)
return date.toLocaleString('zh-CN')
}
// 生命周期
onMounted(async () => {
console.log('UserManager 组件挂载')
await Promise.all([loadUserTree(), loadUsers(1, pageSize.value)])
// 预加载XLSX库
loadXLSX()
})
onBeforeUnmount(() => {
if (loadUsersDebounceTimer) {
clearTimeout(loadUsersDebounceTimer)
}
})
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
/* 行内操作按钮:保证 flex 布局稳定,不被表格列宽挤压换行 */
.action-buttons {
display: inline-flex;
align-items: center;
white-space: nowrap;
}
.action-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* 确保模态框中的表格在移动端可以滚动 */
.overflow-x-auto {
-webkit-overflow-scrolling: touch;
}
.tree-group + .tree-group {
margin-top: 0.25rem;
}
.tree-branch {
margin-left: 1rem;
padding-left: 0.75rem;
border-left: 1px solid var(--user-manager-tree-line);
}
.tree-toggle {
width: 1.5rem;
height: 1.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
color: var(--text-muted);
border-radius: 0.375rem;
transition: color 0.2s ease, background-color 0.2s ease;
}
.tree-toggle:hover {
color: var(--color-accent-light);
background: var(--panel-bg-deep);
}
.tree-label {
min-width: 0;
width: 100%;
height: 1.75rem;
padding: 0 0.5rem;
display: flex;
align-items: center;
gap: 0.375rem;
border-radius: 0.5rem;
color: var(--text-muted-light);
font-size: 0.75rem;
font-weight: 700;
text-align: left;
transition: color 0.2s ease, background-color 0.2s ease;
}
.tree-label:hover {
color: var(--text-primary-lighter);
background: var(--panel-bg-deep);
}
.tree-label-active {
color: var(--color-accent-light);
background: var(--user-manager-tree-active-bg);
}
.tree-count {
flex-shrink: 0;
color: var(--panel-bg-hover);
font-size: 0.6875rem;
font-weight: 900;
}
.tree-users {
margin-left: 1.75rem;
padding: 0.25rem 0 0.35rem;
display: grid;
gap: 0.125rem;
}
.tree-user {
min-width: 0;
width: 100%;
height: 1.75rem;
padding: 0 0.5rem;
display: grid;
grid-template-columns: auto minmax(0, 0.9fr) minmax(0, 1.1fr);
align-items: center;
gap: 0.375rem;
border-radius: 0.5rem;
font-size: 0.75rem;
text-align: left;
transition: background-color 0.2s ease;
}
.tree-user:hover {
background: var(--panel-bg-deep);
}
</style>