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

1862 lines
74 KiB
Vue

<template>
<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="show"
class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
@click="$emit('close')"
>
<div
class="bg-bg-secondary border border-border-secondary w-full max-w-2xl rounded-xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
@click.stop
>
<!-- 头部 -->
<div class="p-8 pb-4 flex items-center justify-between border-b border-border-secondary-50">
<div>
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-info-10 flex items-center justify-center text-info"
>
<Layers :size="20" />
</div>
{{ locale.title }}
</h3>
<p class="text-xs text-text-tertiary mt-1 ml-13">
{{ locale.desc }}
</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all"
@click="$emit('close')"
>
<X :size="20" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-8 pt-6 custom-scrollbar space-y-8">
<!-- 更新方式选择 -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'grade-only'
? 'bg-info-5 border-info-50 ring-4 ring-color-collab-10'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
]"
>
<input v-model="updateType" type="radio" value="grade-only" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'grade-only'
? 'bg-info text-text-primary'
: 'bg-bg-tertiary text-text-tertiary group-hover:text-text-secondary'
]"
>
<Calendar :size="18" />
</div>
<div
v-if="updateType === 'grade-only'"
class="w-5 h-5 rounded-full bg-info flex items-center justify-center"
>
<Check :size="12" class="text-text-primary" />
</div>
</div>
<span class="text-sm font-black text-text-primary uppercase tracking-widest"
>{{ locale.updateTypes.gradeOnly.title }}</span
>
<span class="text-[10px] text-text-tertiary mt-1 font-medium leading-relaxed"
>{{ locale.updateTypes.gradeOnly.desc }}</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'excel-batch'
? 'bg-success-5 border-success-50 ring-4 ring-success-10'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
]"
>
<input v-model="updateType" type="radio" value="excel-batch" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'excel-batch'
? 'bg-success text-text-primary'
: 'bg-bg-tertiary text-text-tertiary group-hover:text-text-secondary'
]"
>
<FileSpreadsheet :size="18" />
</div>
<div
v-if="updateType === 'excel-batch'"
class="w-5 h-5 rounded-full bg-success flex items-center justify-center"
>
<Check :size="12" class="text-text-primary" />
</div>
</div>
<span class="text-sm font-black text-text-primary uppercase tracking-widest"
>{{ locale.updateTypes.excelBatch.title }}</span
>
<span class="text-[10px] text-text-tertiary mt-1 font-medium leading-relaxed"
>{{ locale.updateTypes.excelBatch.desc }}</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'status-batch'
? 'bg-warning-5 border-warning-50 ring-4 ring-warning-10'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
]"
>
<input v-model="updateType" type="radio" value="status-batch" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'status-batch'
? 'bg-warning text-text-primary'
: 'bg-bg-tertiary text-text-tertiary group-hover:text-text-secondary'
]"
>
<ShieldAlert :size="18" />
</div>
<div
v-if="updateType === 'status-batch'"
class="w-5 h-5 rounded-full bg-warning flex items-center justify-center"
>
<Check :size="12" class="text-text-primary" />
</div>
</div>
<span class="text-sm font-black text-text-primary uppercase tracking-widest"
>{{ locale.updateTypes.statusBatch.title }}</span
>
<span class="text-[10px] text-text-tertiary mt-1 font-medium leading-relaxed"
>{{ locale.updateTypes.statusBatch.desc }}</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'song-admin-batch'
? 'bg-primary-5 border-primary-50 ring-4 ring-primary-10'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
]"
>
<input v-model="updateType" type="radio" value="song-admin-batch" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'song-admin-batch'
? 'bg-primary text-text-primary'
: 'bg-bg-tertiary text-text-tertiary group-hover:text-text-secondary'
]"
>
<Music :size="18" />
</div>
<div
v-if="updateType === 'song-admin-batch'"
class="w-5 h-5 rounded-full bg-primary flex items-center justify-center"
>
<Check :size="12" class="text-text-primary" />
</div>
</div>
<span class="text-sm font-black text-text-primary uppercase tracking-widest"
>{{ locale.updateTypes.songAdminBatch.title }}</span
>
<span class="text-[10px] text-text-tertiary mt-1 font-medium leading-relaxed"
>{{ locale.updateTypes.songAdminBatch.desc }}</span
>
</label>
</div>
<!-- 学生选择面板 (年级更新和状态更新共用) -->
<div
v-if="['grade-only', 'status-batch'].includes(updateType)"
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<div class="p-6 bg-bg-primary-50 border border-border-secondary-50 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-text-tertiary uppercase tracking-widest"
>
<Filter :size="14" class="text-info" />
{{ locale.studentFilter.title }}
</div>
<div class="grid grid-cols-1 md: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.studentFilter.currentGrade }}</label
>
<CustomSelect
v-model="gradeFilter"
:options="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.studentFilter.allGrades"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.studentFilter.currentClass }}</label
>
<CustomSelect
v-model="classFilter"
:options="classOptions"
label-key="label"
value-key="value"
:placeholder="locale.studentFilter.allClasses"
class-name="w-full"
/>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between ml-1">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>{{ getNestedText('studentFilter', 'selectUsers', selectedUserIds?.length || 0, filteredUsers?.length || 0) }}</label
>
<button
class="text-[10px] font-black text-info hover:text-info uppercase tracking-widest transition-colors"
@click="toggleSelectAll"
>
{{ isAllSelected ? locale.studentFilter.clearSelection : locale.studentFilter.selectAll }}
</button>
</div>
<div
class="max-h-48 overflow-y-auto rounded-lg border border-border-secondary bg-bg-primary p-2 custom-scrollbar"
>
<div
v-if="filteredUsers?.length === 0"
class="py-10 text-center text-xs text-text-disabled font-medium"
>
{{ locale.studentFilter.noMatchedUsers }}
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-1">
<label
v-for="user in filteredUsers"
:key="user.id"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-bg-secondary-50 cursor-pointer transition-colors group"
>
<input
v-model="selectedUserIds"
:value="user.id"
type="checkbox"
class="w-4 h-4 rounded-md border-border-tertiary bg-bg-primary text-info focus:ring-color-collab-10"
>
<div class="flex flex-col">
<span
class="text-xs font-bold text-text-primary group-hover:text-info transition-colors"
>{{ user.name }}</span
>
<span class="text-[10px] text-text-disabled font-mono">{{
user.username
}}</span>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- 目标年级设置 -->
<div v-if="updateType === 'grade-only'" class="p-6 bg-info-5 border border-info-20 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-info uppercase tracking-widest"
>
<Save :size="14" />
{{ locale.gradeSettings.title }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.gradeSettings.targetGrade }}</label
>
<div class="relative group">
<Calendar
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary group-focus-within:text-info transition-colors"
:size="16"
/>
<input
v-model="targetGrade"
type="text"
:placeholder="locale.gradeSettings.targetGradePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-info-30 transition-all text-text-primary"
>
</div>
</div>
<label
class="flex items-center gap-3 p-3 bg-bg-primary border border-border-secondary rounded-lg cursor-pointer hover:border-border-tertiary transition-all"
>
<input
v-model="keepClass"
type="checkbox"
class="w-5 h-5 rounded-md border-border-tertiary bg-bg-primary text-info focus:ring-color-collab-10"
>
<span class="text-xs font-bold text-text-secondary">{{ locale.gradeSettings.keepClass }}</span>
</label>
</div>
</div>
<!-- 目标状态设置 -->
<div v-if="updateType === 'status-batch'" class="p-6 bg-warning-5 border border-warning-20 rounded-xl space-y-6">
<div class="flex items-center gap-2 text-xs font-black text-warning uppercase tracking-widest">
<Save :size="14" />
{{ locale.statusSettings.title }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1">{{ locale.statusSettings.sourceStatus }}</label>
<CustomSelect
v-model="sourceStatus"
:options="sourceStatusOptions"
label-key="label"
value-key="value"
:placeholder="locale.statusSettings.sourceStatusPlaceholder"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1">{{ locale.statusSettings.targetStatus }}</label>
<CustomSelect
v-model="targetStatus"
:options="statusOptions"
label-key="label"
value-key="value"
:placeholder="locale.statusSettings.targetStatusPlaceholder"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1">{{ locale.statusSettings.reason }}</label>
<div class="relative group">
<MessageSquare class="absolute left-4 top-3 text-text-secondary group-focus-within:text-warning transition-colors" :size="16" />
<textarea
v-model="statusReason"
rows="2"
:placeholder="locale.statusSettings.reasonPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-warning-30 transition-all text-text-primary resize-none"
></textarea>
</div>
</div>
</div>
</div>
</div>
<!-- 歌曲管理员批量更新面板 -->
<div
v-if="updateType === 'song-admin-batch'"
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<div class="p-6 bg-bg-primary-50 border border-border-secondary-50 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-text-tertiary uppercase tracking-widest"
>
<Filter :size="14" class="text-primary" />
{{ getNestedText('songAdminSettings', 'scope') }}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ getNestedText('songAdminSettings', 'currentGrade') }}</label
>
<CustomSelect
v-model="songAdminGradeFilter"
:options="songAdminGradeOptions"
label-key="label"
value-key="value"
:placeholder="getNestedText('songAdminSettings', 'allGrades')"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ getNestedText('songAdminSettings', 'currentClass') }}</label
>
<CustomSelect
v-model="songAdminClassFilter"
:options="songAdminClassOptions"
label-key="label"
value-key="value"
:placeholder="getNestedText('songAdminSettings', 'allClasses')"
class-name="w-full"
/>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between ml-1">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>{{ getNestedText('songAdminSettings', 'selectUsers', songAdminIds?.length || 0, filteredSongAdminUsers?.length || 0) }}</label>
<button
class="text-[10px] font-black text-primary hover:text-info uppercase tracking-widest transition-colors"
@click="toggleSelectAllSongAdmin"
>
{{ isAllSongAdminSelected ? getNestedText('songAdminSettings', 'clearSelection') : getNestedText('songAdminSettings', 'selectAll') }}
</button>
</div>
<div
class="max-h-48 overflow-y-auto rounded-lg border border-border-secondary bg-bg-primary p-2 custom-scrollbar"
>
<div
v-if="filteredSongAdminUsers?.length === 0"
class="py-10 text-center text-xs text-text-disabled font-medium"
>
{{ getNestedText('songAdminSettings', 'noMatchedUsers') }}
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-1">
<label
v-for="user in filteredSongAdminUsers"
:key="user.id"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-bg-secondary-50 cursor-pointer transition-colors group"
>
<input
v-model="songAdminIds"
:value="user.id"
type="checkbox"
class="w-4 h-4 rounded-md border-border-tertiary bg-bg-primary text-primary focus:ring-primary-10"
>
<div class="flex flex-col">
<span
class="text-xs font-bold text-text-primary group-hover:text-primary transition-colors"
>{{ user.name }}</span>
<span class="text-[10px] text-text-disabled font-mono">{{
user.username
}}</span>
</div>
<span class="ml-auto text-[10px] font-bold text-text-disabled">
{{ user.grade || '-' }} {{ user.class || '-' }}
</span>
</label>
</div>
</div>
</div>
</div>
<!-- 歌曲管理员更新目标设置 -->
<div class="p-6 bg-primary-5 border border-primary-20 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-primary uppercase tracking-widest"
>
<Save :size="14" />
{{ getNestedText('songAdminSettings', 'targetSettings') }}
</div>
<p class="text-[10px] text-primary-80 leading-relaxed">
{{ getNestedText('songAdminSettings', 'desc') }}
</p>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ getNestedText('songAdminSettings', 'targetGrade') }}</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="songAdminTargetGrade"
type="text"
:placeholder="getNestedText('songAdminSettings', 'targetGradePlaceholder')"
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ getNestedText('songAdminSettings', 'targetClass') }}</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="songAdminTargetClass"
type="text"
:placeholder="getNestedText('songAdminSettings', 'targetClassPlaceholder')"
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-primary-30 transition-all text-text-primary"
>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ getNestedText('songAdminSettings', 'targetStatus') }}</label>
<CustomSelect
v-model="songAdminTargetStatus"
:options="statusOptions"
label-key="label"
value-key="value"
:placeholder="getNestedText('songAdminSettings', 'targetStatusPlaceholder')"
class-name="w-full"
/>
</div>
</div>
</div>
</div>
<!-- Excel 批量更新面板 -->
<div
v-if="updateType === 'excel-batch'"
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<!-- 文件上传区 -->
<div
:class="[
'relative group cursor-pointer transition-all',
isDragOver ? 'scale-[0.99]' : ''
]"
@drop="handleDrop"
@dragover.prevent
@dragenter.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@click="$refs.fileInput.click()"
>
<input
ref="fileInput"
accept=".xlsx,.xls"
class="hidden"
type="file"
@change="handleFileSelect"
>
<div
:class="[
'w-full py-12 border-2 border-dashed rounded-xl transition-all flex flex-col items-center justify-center gap-4',
isDragOver
? 'border-success bg-success-10'
: 'border-border-secondary hover:border-success-50 hover:bg-success-5'
]"
>
<div
:class="[
'w-16 h-16 rounded-lg bg-bg-secondary flex items-center justify-center transition-colors shadow-xl',
isDragOver ? 'text-success' : 'text-text-disabled group-hover:text-success'
]"
>
<Upload :size="32" />
</div>
<div class="text-center">
<p class="text-base font-black text-text-primary tracking-tight">
{{ locale.excelUpload.dragTitle }}
</p>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.excelUpload.or }} <span class="text-success font-bold">{{ locale.excelUpload.chooseFile }}</span> {{ locale.excelUpload.supportedFormats }}
</p>
</div>
</div>
</div>
<!-- 匹配方式选择 -->
<div class="p-5 bg-bg-primary border border-border-secondary rounded-xl space-y-4">
<div
class="flex items-center gap-2 text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>
<Filter :size="12" />
{{ locale.matchType.title }}
</div>
<div class="flex gap-3">
<label
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 transition-all cursor-pointer text-xs font-black uppercase tracking-widest',
matchType === 'username'
? 'bg-primary-10 border-primary-50 text-primary ring-4 ring-primary-10'
: 'bg-bg-secondary border-border-secondary text-text-disabled hover:border-border-tertiary hover:text-text-tertiary'
]"
>
<input v-model="matchType" type="radio" value="username" class="sr-only" />
<span v-if="matchType === 'username'" class="w-2 h-2 rounded-full bg-primary" />
{{ locale.matchType.byUsername }}
</label>
<label
:class="[
'flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-xl border-2 transition-all cursor-pointer text-xs font-black uppercase tracking-widest',
matchType === 'name'
? 'bg-success-10 border-success-50 text-success ring-4 ring-success-10'
: 'bg-bg-secondary border-border-secondary text-text-disabled hover:border-border-tertiary hover:text-text-tertiary'
]"
>
<input v-model="matchType" type="radio" value="name" class="sr-only" />
<span v-if="matchType === 'name'" class="w-2 h-2 rounded-full bg-success" />
{{ locale.matchType.byName }}
</label>
</div>
<p class="text-[10px] text-text-disabled leading-relaxed">
<template v-if="matchType === 'username'">
{{ locale.matchType.usernamePrefix }}<span class="text-primary font-bold">{{ locale.fields.username }}</span>{{ locale.matchType.usernameSuffix }}
</template>
<template v-else>
{{ locale.matchType.namePrefix }}<span class="text-success font-bold">{{ locale.fields.realName }}</span>{{ locale.matchType.nameMiddle }}<span class="text-success font-bold">{{ locale.fields.username }}</span>{{ locale.matchType.nameSuffix }}
</template>
</p>
</div>
<!-- 模板与说明 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="md:col-span-2 p-5 bg-bg-primary border border-border-secondary rounded-xl space-y-3"
>
<div
class="flex items-center gap-2 text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>
<Info :size="12" />
{{ locale.fileSpec.title }}
</div>
<ul class="text-[10px] text-text-disabled space-y-1.5 font-medium leading-relaxed">
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-bg-quaternary mt-1.5" />
{{ locale.fileSpec.headerLine }}
</li>
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-bg-quaternary mt-1.5" />
<template v-if="matchType === 'username'">
<span class="text-primary font-bold">{{ locale.fields.username }}</span> {{ locale.fileSpec.usernameColumn }}
</template>
<template v-else>
<span class="text-success font-bold">{{ locale.fields.name }}</span> {{ locale.fileSpec.nameColumn }}
</template>
</li>
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-bg-quaternary mt-1.5" />
{{ locale.fileSpec.blankKeepsOriginal }}
</li>
</ul>
</div>
<button
class="p-5 bg-success-5 border border-success-20 hover:border-success-40 rounded-xl transition-all flex flex-col items-center justify-center gap-2 group"
@click="downloadTemplate"
>
<div
class="w-10 h-10 rounded-lg bg-success text-text-primary flex items-center justify-center shadow-lg shadow-[var(--success-glow-20)] group-hover:scale-110 transition-transform"
>
<Download :size="20" />
</div>
<span class="text-[10px] font-black text-success uppercase tracking-widest"
>{{ locale.fileSpec.downloadTemplate }}</span
>
</button>
</div>
<!-- 预览表格 -->
<div v-if="excelPreviewData.length > 0" class="space-y-4">
<!-- 外部阻断用户提示 -->
<div
v-if="blockerList.length > 0"
class="p-5 bg-warning-5 border border-warning-20 rounded-2xl space-y-3 animate-in fade-in duration-300"
>
<div class="flex items-center gap-2 text-xs font-black text-warning uppercase tracking-widest">
<AlertCircle :size="16" />
{{ locale.preview.blockersTitle(blockerList.length) }}
</div>
<p class="text-[10px] text-warning-80 leading-relaxed">
{{ locale.preview.blockersDesc }}
</p>
<div class="flex flex-wrap gap-2">
<span
v-for="(blocker, i) in blockerList"
:key="i"
class="px-3 py-1.5 bg-warning-10 border border-warning-20 rounded-lg text-xs font-bold text-warning"
>
{{ blocker.name }}({{ blocker.username }})
</span>
</div>
</div>
<div class="flex items-center justify-between ml-1">
<label class="text-xs font-black text-text-tertiary uppercase tracking-widest"
>{{ locale.preview.dataPreview(previewFilter === 'all' ? excelPreviewData.length : filteredPreviewData.length, excelPreviewData.length) }}</label
>
<div class="flex items-center gap-4">
<button
:class="[
'flex items-center gap-1.5 transition-all',
previewFilter === 'all' || previewFilter === 'pending'
? 'opacity-100'
: 'opacity-40 hover:opacity-70'
]"
@click="previewFilter = previewFilter === 'pending' ? 'all' : 'pending'"
>
<div class="w-2 h-2 rounded-full bg-success" />
<span class="text-[10px] text-text-tertiary font-bold">{{ locale.preview.pending(previewCounts.pending) }}</span>
</button>
<button
:class="[
'flex items-center gap-1.5 transition-all',
previewFilter === 'all' || previewFilter === 'noChange'
? 'opacity-100'
: 'opacity-40 hover:opacity-70'
]"
@click="previewFilter = previewFilter === 'noChange' ? 'all' : 'noChange'"
>
<div class="w-2 h-2 rounded-full bg-bg-quaternary" />
<span class="text-[10px] text-text-tertiary font-bold">{{ locale.preview.noChangeCount(previewCounts.noChange) }}</span>
</button>
<button
:class="[
'flex items-center gap-1.5 transition-all',
previewFilter === 'all' || previewFilter === 'error'
? 'opacity-100'
: 'opacity-40 hover:opacity-70'
]"
@click="previewFilter = previewFilter === 'error' ? 'all' : 'error'"
>
<div class="w-2 h-2 rounded-full bg-error" />
<span class="text-[10px] text-text-tertiary font-bold">{{ locale.preview.errorCount(previewCounts.error) }}</span>
</button>
</div>
</div>
<div class="rounded-xl border border-border-secondary bg-bg-primary overflow-hidden shadow-xl">
<div class="overflow-x-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead
class="bg-bg-secondary-80 text-[10px] font-black text-text-tertiary uppercase tracking-widest border-b border-border-secondary"
>
<tr>
<th class="px-5 py-4 whitespace-nowrap">
{{ matchType === 'username' ? locale.preview.matchUser : locale.preview.matchName }}
</th>
<th class="px-5 py-4 whitespace-nowrap">{{ locale.preview.currentInfo }}</th>
<th class="px-5 py-4 whitespace-nowrap">{{ locale.preview.afterUpdate }}</th>
<th class="px-5 py-4 whitespace-nowrap text-right">{{ locale.preview.matchStatus }}</th>
</tr>
</thead>
<tbody class="divide-y divide-panel-bg-deepest">
<tr
v-for="(row, index) in filteredPreviewData.slice(0, 10)"
:key="index"
:class="[
row.error ? 'bg-error-5' : 'hover:bg-bg-secondary-30 transition-colors'
]"
>
<td class="px-5 py-4">
<div class="flex flex-col">
<span
:class="[
'text-xs font-bold',
row.error ? 'text-error' : 'text-text-primary'
]"
>{{ matchType === 'username' ? row.username : row.name }}</span
>
<span class="text-[10px] text-text-disabled font-medium">{{
matchType === 'username' ? (row.name || '-') : (row.username || '-')
}}</span>
</div>
</td>
<td class="px-5 py-4">
<div class="flex flex-col">
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-text-primary">{{ row.currentGrade || '-' }}</span>
<span class="text-[10px] text-text-secondary">/</span>
<span class="text-xs font-bold text-text-primary">{{ row.currentClass || '-' }}</span>
</div>
<span class="text-[10px] text-text-tertiary font-medium mt-0.5">{{ row.username }}</span>
</div>
</td>
<td class="px-5 py-4">
<div class="flex flex-col">
<template v-if="row.noChange">
<span class="text-xs font-bold text-text-disabled">-</span>
</template>
<template v-else>
<div class="flex items-center gap-1.5">
<span class="text-xs font-bold text-success">{{ row.newGrade || row.currentGrade || '-' }}</span>
<span class="text-[10px] text-text-secondary">/</span>
<span class="text-xs font-bold text-success">{{ row.newClass || row.currentClass || '-' }}</span>
</div>
<span
:class="[
'text-[10px] font-medium mt-0.5',
row.newUsername ? 'text-success' : 'text-text-tertiary'
]"
>{{ row.newUsername || row.username }}</span>
</template>
</div>
</td>
<td class="px-5 py-4 text-right">
<span
v-if="row.error"
class="px-2 py-0.5 bg-error-10 text-error text-[10px] font-black rounded uppercase tracking-tighter border border-error-20"
>
{{ row.error }}
</span>
<span
v-else-if="row.noChange"
class="px-2 py-0.5 bg-bg-tertiary text-text-tertiary text-[10px] font-black rounded uppercase tracking-tighter border border-border-tertiary-50"
>
{{ locale.preview.noChange }}
</span>
<span
v-else
class="px-2 py-0.5 bg-success-10 text-success text-[10px] font-black rounded uppercase tracking-tighter border border-success-20"
>
{{ locale.preview.ready }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="filteredPreviewData.length > 10"
class="p-4 text-center border-t border-border-secondary bg-bg-secondary-20 text-[10px] text-text-disabled font-bold uppercase tracking-widest"
>
{{ locale.preview.moreQueued(filteredPreviewData.length - 10) }}
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div
v-if="error"
class="p-4 bg-error-10 border border-error-20 rounded-2xl flex items-center gap-3 text-error text-xs animate-in shake duration-300"
>
<AlertCircle :size="16" />
{{ error }}
</div>
<!-- 进度条 -->
<div
v-if="updateType === 'excel-batch' && updateProgressText"
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">{{ updateProgressText }}</span>
<span class="text-xs font-black text-success">{{ updateProgress }}%</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: updateProgress + '%' }"
/>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="p-8 pt-4 border-t border-border-secondary-50 bg-bg-secondary-50 flex gap-3">
<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="$emit('close')"
>
{{ locale.actions.cancel }}
</button>
<button
:disabled="loading || !canUpdate"
:class="[
'flex-[2] px-6 py-4 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 active:scale-95',
updateType === 'excel-batch'
? 'bg-success hover:bg-success shadow-[var(--success-glow-20)]'
: updateType === 'status-batch'
? 'bg-warning hover:bg-warning shadow-[var(--warning-glow-20)]'
: updateType === 'song-admin-batch'
? 'bg-primary hover:bg-primary shadow-[var(--primary-glow)]'
: 'bg-info hover:bg-info shadow-[var(--info-glow-20)]'
]"
@click="performUpdate"
>
<RefreshCw v-if="loading" class="animate-spin" :size="16" />
<Save v-else :size="16" />
{{ loading ? locale.actions.submitting : locale.actions.confirm }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { computed, ref, watch, nextTick } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useUserFilters } from '~/composables/useUserFilters'
import { useLocale } from '~/utils/locale'
import {
Layers,
X,
Calendar,
FileSpreadsheet,
Check,
Filter,
ChevronDown,
Save,
Upload,
Download,
Briefcase,
Info,
AlertCircle,
RefreshCw,
CheckCircle2,
ShieldAlert,
MessageSquare,
Music
} from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const props = defineProps({
show: Boolean,
users: Array
})
const emit = defineEmits(['close', 'update-success'])
// 响应式数据
const updateType = ref('grade-only')
const loading = ref(false)
const error = ref('')
// 仅更新年级相关
const gradeFilter = ref('')
const classFilter = ref('')
const selectedUserIds = ref([])
const targetGrade = ref('')
const keepClass = ref(true)
// 状态批量更新相关
const sourceStatus = ref('')
const targetStatus = ref('')
const statusReason = ref('')
// 歌曲管理员批量更新相关
const songAdminGradeFilter = ref('')
const songAdminClassFilter = ref('')
const songAdminIds = ref([])
const songAdminTargetGrade = ref('')
const songAdminTargetClass = ref('')
const songAdminTargetStatus = ref('')
// Excel批量更新相关
const isDragOver = ref(false)
const excelPreviewData = ref([])
const fileInput = ref(null)
const matchType = ref('username')
const rawExcelData = ref(null)
const blockerList = ref([])
const previewFilter = ref('all')
// 批量更新进度
const updateProgress = ref(0)
const updateProgressText = ref('')
const updateTotalBatches = ref(0)
const updateCurrentBatch = ref(0)
// 所有用户的年级班级信息
// 服务
const auth = useAuth()
const userFilters = useUserFilters()
const { admin } = useLocale()
const locale = computed(() => {
const base = admin.value?.userManager?.batchUpdateModal || {}
const emptyText = () => ''
return useSafeLocale({
...base,
updateTypes: {
gradeOnly: {},
excelBatch: {},
statusBatch: {},
songAdminBatch: {},
...(base.updateTypes || {})
},
fields: { ...(base.fields || {}) },
studentFilter: {
selectUsers: emptyText,
...(base.studentFilter || {})
},
gradeSettings: { ...(base.gradeSettings || {}) },
statusSettings: { ...(base.statusSettings || {}) },
statusOptions: { ...(base.statusOptions || {}) },
excelUpload: { ...(base.excelUpload || {}) },
matchType: { ...(base.matchType || {}) },
fileSpec: { ...(base.fileSpec || {}) },
preview: {
blockersTitle: emptyText,
dataPreview: emptyText,
pending: emptyText,
noChangeCount: emptyText,
errorCount: emptyText,
moreQueued: emptyText,
...(base.preview || {})
},
actions: { ...(base.actions || {}) },
template: {
headers: {},
...(base.template || {})
},
progress: {
processing: emptyText,
completed: emptyText,
...(base.progress || {})
},
messages: {
partialGradeSuccess: emptyText,
gradeSuccess: emptyText,
partialExcelSuccess: emptyText,
excelFailed: emptyText,
partialStatusSuccess: emptyText,
songAdminNoChanges: emptyText,
...(base.messages || {})
},
errors: {
processExcelFailed: emptyText,
fieldRequired: emptyText,
duplicateTarget: emptyText,
usernameOccupied: emptyText,
batchUpdateFailed: emptyText,
updateFailedWithEtc: emptyText,
...(base.errors || {})
}
})
})
const getNestedText = (section, key, ...args) => {
const message = locale.value?.[section]?.[key]
if (typeof message === 'function') return message(...args)
if (typeof message === 'string') {
return message.replace(/{(\d+)}/g, (match, index) =>
args[index] !== undefined ? String(args[index]) : match
)
}
return message || key
}
const excelColumnKeys = computed(() => ({
username: locale.value?.template?.headers?.username,
name: locale.value?.template?.headers?.name,
grade: locale.value?.template?.headers?.grade,
class: locale.value?.template?.headers?.class,
newUsername: locale.value?.template?.headers?.newUsername
}))
const statusOptions = computed(() => [
{ label: locale.value?.statusOptions?.active || '在校', value: 'active' },
{ label: locale.value?.statusOptions?.graduate || '已毕业', value: 'graduate' },
{ label: locale.value?.statusOptions?.withdrawn || '已退学', value: 'withdrawn' }
])
const sourceStatusOptions = computed(() => [
{ label: locale.value?.statusOptions?.all || '全部状态', value: '' },
...statusOptions.value
])
// 计算属性
const computedUsers = computed(() => {
// 必须优先使用全量数据 allUsers,如果正在加载则等待加载完成。
// 只有在尚未触发加载且需要临时展示时才 fallback 到 props.users。
return userFilters.isLoaded.value
? userFilters.allUsers.value
: props.users || []
})
const availableGrades = computed(() => {
return userFilters.getAvailableGrades(computedUsers.value)
})
const availableClasses = computed(() => {
return userFilters.getAvailableClasses(computedUsers.value, gradeFilter.value)
})
watch(() => gradeFilter.value, () => {
classFilter.value = ''
})
const gradeOptions = computed(() => {
return [
{ label: locale.value?.studentFilter?.allGrades || '全部年级', value: '' },
...availableGrades.value.map((g) => ({ label: g, value: g }))
]
})
const classOptions = computed(() => {
return [
{ label: locale.value?.studentFilter?.allClasses || '全部班级', value: '' },
...availableClasses.value.map((c) => ({ label: c, value: c }))
]
})
const songAdminUsers = computed(() => {
return computedUsers.value.filter((u) => u.role === 'SONG_ADMIN')
})
const songAdminAvailableGrades = computed(() => {
const grades = new Set()
songAdminUsers.value.forEach((u) => {
if (u.grade) grades.add(u.grade)
})
return Array.from(grades)
})
const songAdminAvailableClasses = computed(() => {
const classes = new Set()
let pool = songAdminUsers.value
if (songAdminGradeFilter.value) {
pool = pool.filter((u) => u.grade === songAdminGradeFilter.value)
}
pool.forEach((u) => {
if (u.class) classes.add(u.class)
})
return Array.from(classes)
})
watch(() => songAdminGradeFilter.value, () => {
songAdminClassFilter.value = ''
})
const songAdminGradeOptions = computed(() => {
return [
{ label: getNestedText('songAdminSettings', 'allGrades'), value: '' },
...songAdminAvailableGrades.value.map((g) => ({ label: g, value: g }))
]
})
const songAdminClassOptions = computed(() => {
return [
{ label: getNestedText('songAdminSettings', 'allClasses'), value: '' },
...songAdminAvailableClasses.value.map((c) => ({ label: c, value: c }))
]
})
const filteredSongAdminUsers = computed(() => {
let filtered = songAdminUsers.value
if (songAdminGradeFilter.value) {
filtered = filtered.filter((u) => u.grade === songAdminGradeFilter.value)
}
if (songAdminClassFilter.value) {
filtered = filtered.filter((u) => u.class === songAdminClassFilter.value)
}
return filtered
})
const isAllSongAdminSelected = computed(() => {
if (filteredSongAdminUsers.value.length === 0) return false
const selectedSet = new Set(songAdminIds.value)
return filteredSongAdminUsers.value.every((u) => selectedSet.has(u.id))
})
const toggleSelectAllSongAdmin = () => {
const filteredIds = filteredSongAdminUsers.value.map((u) => u.id)
const filteredSet = new Set(filteredIds)
if (isAllSongAdminSelected.value) {
songAdminIds.value = songAdminIds.value.filter((id) => !filteredSet.has(id))
} else {
const newSelections = new Set([...songAdminIds.value, ...filteredIds])
songAdminIds.value = Array.from(newSelections)
}
}
const performSongAdminBatchUpdate = async () => {
const updates = songAdminIds.value.map((userId) => {
const updateData = { userId }
if (songAdminTargetGrade.value.trim()) updateData.grade = songAdminTargetGrade.value.trim()
if (songAdminTargetClass.value.trim()) updateData.class = songAdminTargetClass.value.trim()
if (songAdminTargetStatus.value) updateData.status = songAdminTargetStatus.value
return updateData
})
// 后端单次批量更新上限 100 条,超出时分批提交
const batchSize = 50
let totalUpdated = 0
let totalFailed = 0
let firstErrorDetail = ''
for (let i = 0; i < updates.length; i += batchSize) {
const batch = updates.slice(i, i + batchSize)
try {
const response = await $fetch('/api/admin/users/batch-update', {
method: 'POST',
body: { updates: batch },
...auth.getAuthConfig()
})
if (!response?.success) {
throw new Error(response?.message || getNestedText('errors', 'songAdminUpdateFailed'))
}
totalUpdated += response.data?.summary?.success || 0
totalFailed += response.data?.summary?.failed || 0
if (!firstErrorDetail && response.data?.errors?.length > 0) {
firstErrorDetail = response.data.errors[0].error
}
} catch (err) {
console.error('歌曲管理员批量更新失败:', err)
totalFailed += batch.length
}
}
// 目标字段与现状一致等无实际变更场景
if (totalUpdated === 0 && totalFailed === 0) {
if (window.$showNotification) {
window.$showNotification(getNestedText('messages', 'songAdminNoChanges'), 'warning')
}
return
}
if (totalFailed > 0) {
if (totalUpdated > 0) {
if (window.$showNotification) {
window.$showNotification(getNestedText('messages', 'partialSongAdminSuccess', totalFailed), 'warning')
}
} else {
throw new Error(getNestedText('errors', 'updateFailedWithEtc', firstErrorDetail || getNestedText('errors', 'songAdminUpdateFailed')))
}
} else {
if (window.$showNotification) {
window.$showNotification(getNestedText('messages', 'songAdminSuccess', totalUpdated), 'success')
}
}
}
const filteredUsers = computed(() => {
let filtered = computedUsers.value
if (gradeFilter.value) {
filtered = filtered.filter((s) => s.grade === gradeFilter.value)
}
if (classFilter.value) {
filtered = filtered.filter((s) => s.class === classFilter.value)
}
return filtered
})
const isAllSelected = computed(() => {
if (filteredUsers.value.length === 0) return false
const selectedSet = new Set(selectedUserIds.value)
return filteredUsers.value.every(u => selectedSet.has(u.id))
})
const canUpdate = computed(() => {
if (updateType.value === 'grade-only') {
return selectedUserIds.value.length > 0 && targetGrade.value.trim()
} else if (updateType.value === 'excel-batch') {
return excelPreviewData.value.length > 0 && excelPreviewData.value.some((row) => !row.error && !row.noChange)
} else if (updateType.value === 'status-batch') {
return selectedUserIds.value.length > 0 && targetStatus.value && statusReason.value.trim()
} else if (updateType.value === 'song-admin-batch') {
return (
songAdminIds.value.length > 0 &&
(songAdminTargetGrade.value.trim() || songAdminTargetClass.value.trim() || songAdminTargetStatus.value)
)
}
return false
})
const filteredPreviewData = computed(() => {
if (previewFilter.value === 'all') return excelPreviewData.value
if (previewFilter.value === 'pending') return excelPreviewData.value.filter((r) => !r.error && !r.noChange)
if (previewFilter.value === 'noChange') return excelPreviewData.value.filter((r) => r.noChange)
if (previewFilter.value === 'error') return excelPreviewData.value.filter((r) => r.error)
return excelPreviewData.value
})
const previewCounts = computed(() => {
const data = excelPreviewData.value
return {
pending: data.filter((r) => !r.error && !r.noChange).length,
noChange: data.filter((r) => r.noChange).length,
error: data.filter((r) => r.error).length
}
})
// 方法
const toggleSelectAll = () => {
const filteredIds = filteredUsers.value.map((s) => s.id)
const filteredSet = new Set(filteredIds)
if (isAllSelected.value) {
selectedUserIds.value = selectedUserIds.value.filter((id) => !filteredSet.has(id))
} else {
const newSelections = new Set([...selectedUserIds.value, ...filteredIds])
selectedUserIds.value = Array.from(newSelections)
}
}
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
processExcelFile(file)
}
}
const handleDrop = (event) => {
event.preventDefault()
isDragOver.value = false
const file = event.dataTransfer.files[0]
if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls'))) {
processExcelFile(file)
}
}
const processExcelFile = async (file) => {
try {
loading.value = true
error.value = ''
// 确保用户数据已加载(强制要求全量数据,防止使用单页 props.users 匹配导致误判)
if (!userFilters.isLoaded.value) {
await fetchAllUsers()
await nextTick()
}
// 动态加载XLSX库
if (typeof window.XLSX === 'undefined') {
await loadXLSX()
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result)
const workbook = window.XLSX.read(data, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
const jsonData = window.XLSX.utils.sheet_to_json(worksheet)
rawExcelData.value = jsonData
parseExcelData(jsonData)
} catch (parseError) {
console.error('解析Excel文件失败:', parseError)
error.value = getNestedText('errors', 'invalidExcelFormat')
loading.value = false
}
}
reader.onerror = () => {
console.error('读取文件失败')
error.value = getNestedText('errors', 'readFileFailed')
loading.value = false
}
reader.readAsArrayBuffer(file)
} catch (err) {
console.error('处理Excel文件失败:', err)
const errorMessage = err && err.message ? err.message : getNestedText('errors', 'unknownExcelError')
error.value = getNestedText('errors', 'processExcelFailed', errorMessage)
loading.value = false
}
}
const parseExcelData = (jsonData) => {
const previewData = []
const userMapByUsername = new Map()
const userMapByName = new Map()
// 创建双向用户映射
computedUsers.value.forEach((user) => {
if (user.username) {
userMapByUsername.set(user.username, user)
userMapByUsername.set(user.username.trim().toLowerCase(), user)
}
if (user.name) {
const normalizedName = user.name.trim().toLowerCase()
if (userMapByName.has(normalizedName)) {
userMapByName.set(normalizedName, 'AMBIGUOUS')
} else {
userMapByName.set(normalizedName, user)
}
}
})
const columnKeys = excelColumnKeys.value
const getRowValue = (row, key, fallbacks, defaultValue = '') => {
const resolvedKey = typeof key === 'function' ? key() : key
if (resolvedKey && row[resolvedKey] !== undefined) return row[resolvedKey]
for (const fallback of fallbacks) {
if (row[fallback] !== undefined) return row[fallback]
}
return defaultValue
}
jsonData.forEach((row, index) => {
if (!row || typeof row !== 'object') return
const rawUsername = getRowValue(row, columnKeys.username, ['用户名', 'username'])
const rawName = getRowValue(row, columnKeys.name, ['姓名', 'name'])
const rawGrade = getRowValue(row, columnKeys.grade, ['年级', 'grade'])
const rawClass = getRowValue(row, columnKeys.class, ['班级', 'class'])
const rawExplicitNewUsername = getRowValue(row, columnKeys.newUsername, ['新用户名', 'new_username'])
const username = rawUsername != null ? String(rawUsername).trim() : ''
const name = rawName != null ? String(rawName).trim().toLowerCase() : ''
const newGrade = rawGrade != null ? String(rawGrade).trim() : ''
const newClass = rawClass != null ? String(rawClass).trim() : ''
const explicitNewUsername = rawExplicitNewUsername != null ? String(rawExplicitNewUsername).trim() : ''
const newUsername = explicitNewUsername || (matchType.value === 'name' ? username : '')
const keyValue = matchType.value === 'username' ? username : name
const keyLabel = matchType.value === 'username' ? getNestedText('fields', 'username') : getNestedText('fields', 'name')
if (!keyValue) {
previewData.push({
username: username,
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: getNestedText('errors', 'fieldRequired', keyLabel)
})
return
}
let existingUser = null
if (matchType.value === 'username') {
existingUser =
userMapByUsername.get(username) ||
userMapByUsername.get(username.toLowerCase())
} else {
const match = userMapByName.get(name)
if (match === 'AMBIGUOUS') {
previewData.push({
username: username,
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: getNestedText('errors', 'duplicateNames')
})
return
}
existingUser = match
}
if (!existingUser) {
previewData.push({
username: username,
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: getNestedText('errors', 'userNotFound')
})
return
}
const finalNewGrade = newGrade || undefined
const finalNewClass = newClass || undefined
const finalNewUsername = newUsername || undefined
const hasGradeChange = finalNewGrade !== undefined && finalNewGrade !== (existingUser.grade || '')
const hasClassChange = finalNewClass !== undefined && finalNewClass !== (existingUser.class || '')
const hasUsernameChange = finalNewUsername !== undefined && finalNewUsername !== existingUser.username
if (!hasGradeChange && !hasClassChange && !hasUsernameChange) {
previewData.push({
userId: existingUser.id,
username: existingUser.username,
name: existingUser.name,
currentGrade: existingUser.grade,
currentClass: existingUser.class,
newGrade: undefined,
newClass: undefined,
newUsername: undefined,
noChange: true
})
return
}
let usernameConflict = null
if (hasUsernameChange && finalNewUsername) {
const conflictUser =
userMapByUsername.get(finalNewUsername) ||
userMapByUsername.get(finalNewUsername.toLowerCase())
if (conflictUser && conflictUser.id !== existingUser.id) {
usernameConflict = { userId: conflictUser.id, name: conflictUser.name }
}
}
previewData.push({
userId: existingUser.id,
username: existingUser.username,
name: existingUser.name,
currentGrade: existingUser.grade,
currentClass: existingUser.class,
newGrade: finalNewGrade,
newClass: finalNewClass,
newUsername: finalNewUsername,
usernameConflict: usernameConflict
})
})
const externalBlockers = new Map()
const requestedUsernames = new Map()
const previewMap = new Map(previewData.filter((r) => !r.error).map((r) => [r.userId, r]))
for (const row of previewData) {
if (row.error || row.noChange) continue
if (row.newUsername) {
const previous = requestedUsernames.get(row.newUsername)
if (previous) {
const duplicateTargetMessage = getNestedText('errors', 'duplicateTarget', row.newUsername)
previous.error = duplicateTargetMessage
row.error = duplicateTargetMessage
continue
}
requestedUsernames.set(row.newUsername, row)
}
if (row.usernameConflict) {
const conflictRow = previewMap.get(row.usernameConflict.userId)
const conflictIsChanging =
conflictRow && conflictRow.newUsername && conflictRow.newUsername !== conflictRow.username
if (conflictIsChanging) {
row.usernameConflict = null
} else {
externalBlockers.set(row.usernameConflict.userId, {
username: row.newUsername,
name: row.usernameConflict.name
})
}
}
}
blockerList.value = Array.from(externalBlockers.values())
for (const row of previewData) {
if (row.error || row.noChange) continue
if (row.usernameConflict) {
row.error = getNestedText('errors', 'usernameOccupied', row.newUsername, row.usernameConflict.name)
}
}
excelPreviewData.value = previewData
loading.value = false
}
const loadXLSX = async () => {
return new Promise((resolve, reject) => {
if (typeof window.XLSX !== 'undefined') {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'
script.onload = resolve
script.onerror = (error) => {
console.error('加载XLSX库失败:', error)
reject(new Error(getNestedText('errors', 'loadXlsxFailed')))
}
document.head.appendChild(script)
})
}
const downloadTemplate = async () => {
if (typeof window.XLSX === 'undefined') {
try {
await loadXLSX()
} catch (err) {
if (window.$showNotification) {
window.$showNotification(getNestedText('errors', 'loadXlsxRetry'), 'error')
} else {
alert(getNestedText('errors', 'loadXlsxRetry'))
}
return
}
}
const templateHeaders = locale.value?.template?.headers || {}
const getHeaderText = (value, fallback) => {
if (typeof value === 'function') return value() || fallback
if (typeof value === 'string') return value || fallback
return fallback
}
const headers = {
username: getHeaderText(templateHeaders.username, 'username'),
name: getHeaderText(templateHeaders.name, 'name'),
grade: getHeaderText(templateHeaders.grade, 'grade'),
class: getHeaderText(templateHeaders.class, 'class'),
newUsername: getHeaderText(templateHeaders.newUsername, 'new_username')
}
const templateData = [
{
[headers.username]: 'student001',
[headers.name]: getNestedText('template', 'sampleName1'),
[headers.grade]: '2025',
[headers.class]: getNestedText('template', 'sampleClass1'),
[headers.newUsername]: 'new_student001'
},
{
[headers.username]: 'student002',
[headers.name]: getNestedText('template', 'sampleName2'),
[headers.grade]: '2025',
[headers.class]: getNestedText('template', 'sampleClass2'),
[headers.newUsername]: ''
}
]
const ws = window.XLSX.utils.json_to_sheet(templateData)
const wb = window.XLSX.utils.book_new()
const sheetName = getNestedText('template', 'sheetName')
const fileName = getNestedText('template', 'fileName')
window.XLSX.utils.book_append_sheet(wb, ws, sheetName && sheetName !== 'sheetName' ? sheetName : 'template')
window.XLSX.writeFile(wb, fileName && fileName !== 'fileName' ? fileName : 'batch-update-template.xlsx')
}
// 当匹配方式切换时,重新解析已上传的 Excel 数据
watch(matchType, () => {
if (rawExcelData.value) {
parseExcelData(rawExcelData.value)
}
})
const performUpdate = async () => {
try {
loading.value = true
error.value = ''
if (updateType.value === 'grade-only') {
await performGradeUpdate()
emit('update-success')
emit('close')
} else if (updateType.value === 'excel-batch') {
const result = await performExcelUpdate()
if (result.totalFailed > 0) {
error.value = result.message
if (window.$showNotification) {
window.$showNotification(result.message, 'warning')
}
return
}
excelPreviewData.value = []
// 等待 3 秒让用户看到进度条完成状态
setTimeout(() => {
if (updateProgressText.value) {
emit('close')
}
}, 3000)
} else if (updateType.value === 'status-batch') {
await performStatusUpdate()
emit('update-success')
emit('close')
} else if (updateType.value === 'song-admin-batch') {
await performSongAdminBatchUpdate()
emit('update-success')
emit('close')
}
} catch (err) {
console.error('批量更新失败:', err)
error.value = getNestedText('errors', 'batchUpdateFailed', err?.data?.message || err?.message || err?.statusMessage || getNestedText('errors', 'unknownError'))
} finally {
loading.value = false
}
}
const performGradeUpdate = async () => {
const response = await $fetch('/api/admin/users/batch-grade-update', {
method: 'POST',
body: {
userIds: selectedUserIds.value,
targetGrade: targetGrade.value.trim(),
keepClass: keepClass.value
},
...auth.getAuthConfig()
})
if (!response.success) {
throw new Error(response.message || getNestedText('errors', 'batchUpdateGeneric'))
}
if (response.errors && response.errors.length > 0) {
if (response.updated === 0) {
throw new Error(getNestedText('errors', 'updateFailedWithEtc', response.errors[0].error))
} else {
if (window.$showNotification) {
window.$showNotification(getNestedText('messages', 'partialGradeSuccess', response.failed), 'warning')
}
}
} else {
if (window.$showNotification) {
window.$showNotification(getNestedText('messages', 'gradeSuccess', response.updated), 'success')
}
}
}
const performExcelUpdate = async () => {
const validUpdates = excelPreviewData.value.filter((row) => !row.error && !row.noChange && row.userId)
if (validUpdates.length === 0) {
throw new Error(getNestedText('errors', 'noValidUpdates'))
}
const targetSet = new Set(validUpdates.filter((r) => r.newUsername).map((r) => r.newUsername))
validUpdates.sort((a, b) => {
const aFreesSlot = targetSet.has(a.username)
const bFreesSlot = targetSet.has(b.username)
if (aFreesSlot && !bFreesSlot) return -1
if (!aFreesSlot && bFreesSlot) return 1
return 0
})
updateProgress.value = 0
updateTotalBatches.value = Math.ceil(validUpdates.length / 50)
updateCurrentBatch.value = 0
const batchSize = 50
let totalUpdated = 0
let totalFailed = 0
for (let i = 0; i < validUpdates.length; i += batchSize) {
const batch = validUpdates.slice(i, i + batchSize)
updateCurrentBatch.value = Math.floor(i / batchSize) + 1
updateProgressText.value = getNestedText('progress', 'processing', updateCurrentBatch.value, updateTotalBatches.value)
updateProgress.value = Math.round((updateCurrentBatch.value / updateTotalBatches.value) * 100)
const updates = batch.map((row) => ({
userId: row.userId,
grade: row.newGrade ? String(row.newGrade).trim() : undefined,
class: row.newClass ? String(row.newClass).trim() : undefined,
username: row.newUsername ? String(row.newUsername).trim() : undefined
}))
try {
const result = await $fetch('/api/admin/users/batch-update', {
method: 'POST',
body: { updates },
...auth.getAuthConfig()
})
if (!result?.success) {
throw new Error(result?.message || getNestedText('errors', 'batchRequestFailed'))
}
if (result.data?.summary) {
totalUpdated += result.data.summary.success || 0
totalFailed += result.data.summary.failed || 0
} else {
throw new Error(getNestedText('errors', 'invalidBatchResponse'))
}
} catch (err) {
console.error(`第 ${updateCurrentBatch.value} 批更新失败:`, err)
totalFailed += batch.length
}
}
if (totalFailed > 0) {
const partialMessage = totalUpdated > 0
? getNestedText('messages', 'partialExcelSuccess', totalUpdated, totalFailed)
: getNestedText('messages', 'excelFailed', totalFailed)
// 如果存在更新成功的数据,仍然需要通知父组件刷新列表
if (totalUpdated > 0) {
emit('update-success')
}
// 返回结果给外层统一处理提示,而不是抛出异常打断外层流程
return {
success: false,
totalUpdated,
totalFailed,
message: partialMessage
}
}
updateProgressText.value = getNestedText('progress', 'completed', totalUpdated, totalFailed)
updateProgress.value = 100
if (totalUpdated > 0) {
emit('update-success')
}
return {
success: true,
totalUpdated,
totalFailed,
message: ''
}
}
const performStatusUpdate = async () => {
const response = await $fetch('/api/admin/users/batch-status', {
method: 'PUT',
body: {
userIds: selectedUserIds.value,
sourceStatus: sourceStatus.value || undefined,
status: targetStatus.value,
reason: statusReason.value.trim()
},
...auth.getAuthConfig()
})
if (!response.success) {
if (response.errors && response.errors.length > 0) {
throw new Error(getNestedText('errors', 'updateFailedWithEtc', response.errors[0].error))
}
throw new Error(response.message || getNestedText('errors', 'statusUpdateFailed'))
}
if (response.errors && response.errors.length > 0) {
if (window.$showNotification) {
window.$showNotification(getNestedText('messages', 'partialStatusSuccess', response.errors.length), 'warning')
}
} else {
if (window.$showNotification) {
window.$showNotification(response.message || getNestedText('messages', 'statusSuccess'), 'success')
}
}
}
// 获取所有用户数据
const fetchAllUsers = async () => {
await userFilters.fetchAllUsers()
}
// 监听显示状态
watch(
() => props.show,
(newVal) => {
if (newVal) {
fetchAllUsers()
// 重置状态
selectedUserIds.value = []
excelPreviewData.value = []
matchType.value = 'username'
rawExcelData.value = null
blockerList.value = []
previewFilter.value = 'all'
targetStatus.value = ''
statusReason.value = ''
songAdminGradeFilter.value = ''
songAdminClassFilter.value = ''
songAdminIds.value = []
songAdminTargetGrade.value = ''
songAdminTargetClass.value = ''
songAdminTargetStatus.value = ''
error.value = ''
updateProgress.value = 0
updateProgressText.value = ''
updateTotalBatches.value = 0
updateCurrentBatch.value = 0
}
}
)
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.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);
}
</style>