初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,985 @@
<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-black/80 backdrop-blur-sm"
@click="$emit('close')"
>
<div
class="bg-zinc-900 border border-zinc-800 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-zinc-800/50">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-purple-600/10 flex items-center justify-center text-purple-500"
>
<Layers :size="20" />
</div>
批量更新学生信息
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">
快速调整学生年级、班级或通过 Excel 批量修改
</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 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 md:grid-cols-3 gap-4">
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'grade-only'
? 'bg-purple-500/5 border-purple-500/50 ring-4 ring-purple-500/10'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
>
<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-purple-500 text-white'
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
]"
>
<Calendar :size="18" />
</div>
<div
v-if="updateType === 'grade-only'"
class="w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center"
>
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
>仅更新年级</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
>批量更新选中学生的年级,保持班级不变</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'excel-batch'
? 'bg-emerald-500/5 border-emerald-500/50 ring-4 ring-emerald-500/10'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
>
<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-emerald-500 text-white'
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
]"
>
<FileSpreadsheet :size="18" />
</div>
<div
v-if="updateType === 'excel-batch'"
class="w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center"
>
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
>Excel 批量更新</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
>通过 Excel 文件精确匹配并批量修改学生信息</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'status-batch'
? 'bg-amber-500/5 border-amber-500/50 ring-4 ring-amber-500/10'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
>
<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-amber-500 text-white'
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
]"
>
<ShieldAlert :size="18" />
</div>
<div
v-if="updateType === 'status-batch'"
class="w-5 h-5 rounded-full bg-amber-500 flex items-center justify-center"
>
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
>设置账户状态</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
>批量设置选中学生的账户状态</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-zinc-950/50 border border-zinc-800/50 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
>
<Filter :size="14" class="text-purple-500" />
学生范围筛选
</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-zinc-500 uppercase tracking-widest ml-1"
>当前年级</label
>
<CustomSelect
v-model="gradeFilter"
:options="gradeOptions"
label-key="label"
value-key="value"
placeholder="全部年级"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>当前班级</label
>
<CustomSelect
v-model="classFilter"
:options="classOptions"
label-key="label"
value-key="value"
placeholder="全部班级"
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-zinc-500 uppercase tracking-widest"
>选择学生 ({{ selectedUserIds.length }}/{{ filteredStudents.length }})</label
>
<button
class="text-[10px] font-black text-purple-400 hover:text-purple-300 uppercase tracking-widest transition-colors"
@click="toggleSelectAll"
>
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
</button>
</div>
<div
class="max-h-48 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 p-2 custom-scrollbar"
>
<div
v-if="filteredStudents.length === 0"
class="py-10 text-center text-xs text-zinc-600 font-medium"
>
没有匹配条件的学生
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-1">
<label
v-for="student in filteredStudents"
:key="student.id"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-900/50 cursor-pointer transition-colors group"
>
<input
v-model="selectedUserIds"
:value="student.id"
type="checkbox"
class="w-4 h-4 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
>
<div class="flex flex-col">
<span
class="text-xs font-bold text-zinc-200 group-hover:text-purple-400 transition-colors"
>{{ student.name }}</span
>
<span class="text-[10px] text-zinc-600 font-mono">{{
student.username
}}</span>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- 目标年级设置 -->
<div v-if="updateType === 'grade-only'" class="p-6 bg-purple-500/5 border border-purple-500/20 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-purple-400 uppercase tracking-widest"
>
<Save :size="14" />
目标设置
</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-zinc-500 uppercase tracking-widest ml-1"
>目标年级</label
>
<div class="relative group">
<Calendar
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-purple-500 transition-colors"
:size="16"
/>
<input
v-model="targetGrade"
type="text"
placeholder="例如: 2025"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-purple-500/30 transition-all text-zinc-200"
>
</div>
</div>
<label
class="flex items-center gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-700 transition-all"
>
<input
v-model="keepClass"
type="checkbox"
class="w-5 h-5 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
>
<span class="text-xs font-bold text-zinc-300">保持原有班级不变</span>
</label>
</div>
</div>
<!-- 目标状态设置 -->
<div v-if="updateType === 'status-batch'" class="p-6 bg-amber-500/5 border border-amber-500/20 rounded-xl space-y-6">
<div class="flex items-center gap-2 text-xs font-black text-amber-400 uppercase tracking-widest">
<Save :size="14" />
目标状态设置
</div>
<div class="grid grid-cols-1 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">选择账户状态</label>
<CustomSelect
v-model="targetStatus"
:options="statusOptions"
label-key="label"
value-key="value"
placeholder="请选择目标状态"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">变更原因说明</label>
<div class="relative group">
<MessageSquare class="absolute left-4 top-3 text-zinc-700 group-focus-within:text-amber-500 transition-colors" :size="16" />
<textarea
v-model="statusReason"
rows="2"
placeholder="例如: 2025届学生统一毕业"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200 resize-none"
></textarea>
</div>
</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-emerald-500 bg-emerald-500/10'
: 'border-zinc-800 hover:border-emerald-500/50 hover:bg-emerald-500/5'
]"
>
<div
:class="[
'w-16 h-16 rounded-lg bg-zinc-900 flex items-center justify-center transition-colors shadow-xl',
isDragOver ? 'text-emerald-500' : 'text-zinc-600 group-hover:text-emerald-500'
]"
>
<Upload :size="32" />
</div>
<div class="text-center">
<p class="text-base font-black text-zinc-200 tracking-tight">
拖拽 Excel 文件到此处
</p>
<p class="text-xs text-zinc-500 mt-1">
或 <span class="text-emerald-500 font-bold">点击选择文件</span> (支持 .xlsx /
.xls)
</p>
</div>
</div>
</div>
<!-- 模板与说明 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="md:col-span-2 p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3"
>
<div
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>
<Info :size="12" />
文件格式规范
</div>
<ul class="text-[10px] text-zinc-600 space-y-1.5 font-medium leading-relaxed">
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
第一行为表头:用户名、姓名、年级、班级、新用户名
</li>
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
<span class="text-zinc-400 font-bold">用户名</span> 列用于精确匹配现有账户
</li>
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
字段留空则保持原值不变,不进行覆盖更新
</li>
</ul>
</div>
<button
class="p-5 bg-emerald-500/5 border border-emerald-500/20 hover:border-emerald-500/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-emerald-500 text-white flex items-center justify-center shadow-lg shadow-emerald-900/20 group-hover:scale-110 transition-transform"
>
<Download :size="20" />
</div>
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
>获取模板文件</span
>
</button>
</div>
<!-- 预览表格 -->
<div v-if="excelPreviewData.length > 0" class="space-y-4">
<div class="flex items-center justify-between ml-1">
<label class="text-xs font-black text-zinc-400 uppercase tracking-widest"
>数据预览 ({{ excelPreviewData.length }}条)</label
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500" />
<span class="text-[10px] text-zinc-500 font-bold">待更新</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-500" />
<span class="text-[10px] text-zinc-500 font-bold">错误</span>
</div>
</div>
</div>
<div class="rounded-xl border border-zinc-800 bg-zinc-950 overflow-hidden shadow-xl">
<div class="overflow-x-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead
class="bg-zinc-900/80 text-[10px] font-black text-zinc-500 uppercase tracking-widest border-b border-zinc-800"
>
<tr>
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
<th class="px-5 py-4 whitespace-nowrap">当前信息</th>
<th class="px-5 py-4 whitespace-nowrap">更新后</th>
<th class="px-5 py-4 whitespace-nowrap text-right">匹配状态</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-900">
<tr
v-for="(row, index) in excelPreviewData.slice(0, 10)"
:key="index"
:class="[
row.error ? 'bg-red-500/5' : 'hover:bg-zinc-900/30 transition-colors'
]"
>
<td class="px-5 py-4">
<div class="flex flex-col">
<span
:class="[
'text-xs font-bold',
row.error ? 'text-red-400' : 'text-zinc-200'
]"
>{{ row.username }}</span
>
<span class="text-[10px] text-zinc-600 font-medium">{{
row.name || '-'
}}</span>
</div>
</td>
<td class="px-5 py-4">
<div class="text-[10px] text-zinc-500 font-medium">
{{ row.currentGrade || '-' }}年级 {{ row.currentClass || '-' }}
</div>
</td>
<td class="px-5 py-4">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-emerald-400">{{
row.newGrade || row.currentGrade || '-'
}}</span>
<span class="text-[10px] text-zinc-700">/</span>
<span class="text-xs font-bold text-emerald-400">{{
row.newClass || row.currentClass || '-'
}}</span>
</div>
</td>
<td class="px-5 py-4 text-right">
<span
v-if="row.error"
class="px-2 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-black rounded uppercase tracking-tighter border border-red-500/20"
>
{{ row.error }}
</span>
<span
v-else
class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 text-[10px] font-black rounded uppercase tracking-tighter border border-emerald-500/20"
>
已就绪
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="excelPreviewData.length > 10"
class="p-4 text-center border-t border-zinc-900 bg-zinc-900/20 text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
>
以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div
v-if="error"
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs animate-in shake duration-300"
>
<AlertCircle :size="16" />
{{ error }}
</div>
</div>
<!-- 底部按钮 -->
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50 flex gap-3">
<button
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="$emit('close')"
>
取消操作
</button>
<button
:disabled="loading || !canUpdate"
:class="[
'flex-[2] px-6 py-4 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg active:scale-95',
updateType === 'excel-batch'
? 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-900/20'
: updateType === 'status-batch'
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-900/20'
: 'bg-purple-600 hover:bg-purple-500 shadow-purple-900/20'
]"
@click="performUpdate"
>
<RefreshCw v-if="loading" class="animate-spin" :size="16" />
<Save v-else :size="16" />
{{ loading ? '正在提交更新...' : '确认并开始更新' }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useAuth } from '~/composables/useAuth'
import {
Layers,
X,
Calendar,
FileSpreadsheet,
Check,
Filter,
ChevronDown,
Save,
Upload,
Download,
Info,
AlertCircle,
RefreshCw,
CheckCircle2,
ShieldAlert,
MessageSquare
} from 'lucide-vue-next'
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 targetStatus = ref('')
const statusReason = ref('')
const statusOptions = [
{ label: '正常访问', value: 'active' },
{ label: '限制访问 (毕业生)', value: 'graduate' },
{ label: '限制访问 (退学)', value: 'withdrawn' }
]
// Excel批量更新相关
const isDragOver = ref(false)
const excelPreviewData = ref([])
const fileInput = ref(null)
// 所有用户的年级班级信息
const allGrades = ref([])
const allClasses = ref([])
// 所有学生用户数据
const allStudents = ref([])
// 服务
const auth = useAuth()
// 计算属性
const students = computed(() => {
return allStudents.value.length > 0
? allStudents.value
: props.users.filter((user) => user.role === 'USER')
})
const availableGrades = computed(() => {
return allGrades.value.length > 0
? allGrades.value
: [...new Set(students.value.map((s) => s.grade).filter(Boolean))].sort()
})
const availableClasses = computed(() => {
return allClasses.value.length > 0
? allClasses.value
: [...new Set(students.value.map((s) => s.class).filter(Boolean))].sort()
})
const gradeOptions = computed(() => {
return [
{ label: '全部年级', value: '' },
...availableGrades.value.map((g) => ({ label: g, value: g }))
]
})
const classOptions = computed(() => {
return [
{ label: '全部班级', value: '' },
...availableClasses.value.map((c) => ({ label: c, value: c }))
]
})
const filteredStudents = computed(() => {
let filtered = students.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(() => {
return (
filteredStudents.value.length > 0 &&
selectedUserIds.value.length === filteredStudents.value.length
)
})
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)
} else if (updateType.value === 'status-batch') {
return selectedUserIds.value.length > 0 && targetStatus.value && statusReason.value.trim()
}
return false
})
// 方法
const toggleSelectAll = () => {
if (isAllSelected.value) {
selectedUserIds.value = []
} else {
selectedUserIds.value = filteredStudents.value.map((s) => s.id)
}
}
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 = ''
// 确保学生数据已加载
if (students.value.length === 0) {
console.log('学生数据为空,重新获取数据...')
await fetchAllStudents()
// 等待一小段时间确保数据更新
await new Promise((resolve) => setTimeout(resolve, 100))
}
// 动态加载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)
parseExcelData(jsonData)
} catch (parseError) {
console.error('解析Excel文件失败:', parseError)
error.value = 'Excel文件格式错误,请检查文件格式'
loading.value = false
}
}
reader.onerror = () => {
console.error('读取文件失败')
error.value = '读取文件失败,请重试'
loading.value = false
}
reader.readAsArrayBuffer(file)
} catch (err) {
console.error('处理Excel文件失败:', err)
const errorMessage = err && err.message ? err.message : '未知错误,请检查文件格式或网络连接'
error.value = '处理Excel文件失败: ' + errorMessage
loading.value = false
}
}
const parseExcelData = (jsonData) => {
const previewData = []
const userMap = new Map()
// 创建用户映射,同时处理用户名标准化
students.value.forEach((user) => {
if (user.username) {
const normalizedUsername = user.username.trim().toLowerCase()
userMap.set(normalizedUsername, user)
userMap.set(user.username, user)
}
})
jsonData.forEach((row, index) => {
const username = (row['用户名'] || row['username'] || '').toString().trim()
const name = row['姓名'] || row['name'] || ''
const newGrade = row['年级'] || row['grade'] ? String(row['年级'] || row['grade']).trim() : ''
const newClass = row['班级'] || row['class'] ? String(row['班级'] || row['class']).trim() : ''
const newUsername = row['新用户名'] || row['new_username'] || ''
if (!username) {
previewData.push({
username: '',
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: '用户名不能为空'
})
return
}
const existingUser =
userMap.get(username) ||
userMap.get(username.toLowerCase()) ||
userMap.get(username.toUpperCase())
if (!existingUser) {
previewData.push({
username: username,
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: '用户不存在'
})
return
}
previewData.push({
userId: existingUser.id,
username: username,
name: existingUser.name,
currentGrade: existingUser.grade,
currentClass: existingUser.class,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername
})
})
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('无法加载Excel处理库,请检查网络连接'))
}
document.head.appendChild(script)
})
}
const downloadTemplate = () => {
const templateData = [
{ 用户名: 'student001', 姓名: '张三', 年级: '2025', 班级: '1班', 新用户名: 'new_student001' },
{ 用户名: 'student002', 姓名: '李四', 年级: '2025', 班级: '2班', 新用户名: '' }
]
const ws = window.XLSX.utils.json_to_sheet(templateData)
const wb = window.XLSX.utils.book_new()
window.XLSX.utils.book_append_sheet(wb, ws, '学生信息')
window.XLSX.writeFile(wb, '学生信息批量更新模板.xlsx')
}
const performUpdate = async () => {
try {
loading.value = true
error.value = ''
if (updateType.value === 'grade-only') {
await performGradeUpdate()
} else if (updateType.value === 'excel-batch') {
await performExcelUpdate()
} else if (updateType.value === 'status-batch') {
await performStatusUpdate()
}
emit('update-success')
emit('close')
} catch (err) {
console.error('批量更新失败:', err)
error.value = '批量更新失败: ' + err.message
} 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 || '批量更新失败')
}
}
const performExcelUpdate = async () => {
const validUpdates = excelPreviewData.value.filter((row) => !row.error && row.userId)
if (validUpdates.length === 0) {
throw new Error('没有有效的更新数据')
}
// 分批更新,避免请求过大
const batchSize = 50
for (let i = 0; i < validUpdates.length; i += batchSize) {
const batch = validUpdates.slice(i, i + batchSize)
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
}))
await $fetch('/api/admin/users/batch-update', {
method: 'POST',
body: { updates },
...auth.getAuthConfig()
})
}
}
const performStatusUpdate = async () => {
const response = await $fetch('/api/admin/users/batch-status', {
method: 'PUT',
body: {
userIds: selectedUserIds.value,
status: targetStatus.value,
reason: statusReason.value.trim()
},
...auth.getAuthConfig()
})
if (!response.success) {
throw new Error(response.message || '批量更新状态失败')
}
}
// 获取所有学生用户数据
const fetchAllStudents = async () => {
try {
const response = await $fetch('/api/admin/users', {
method: 'GET',
query: {
page: 1,
limit: 10000,
role: 'USER'
},
...auth.getAuthConfig()
})
if (response.success && response.users) {
const users = response.users
allStudents.value = users
const grades = [...new Set(users.map((u) => u.grade).filter(Boolean))].sort()
const classes = [...new Set(users.map((u) => u.class).filter(Boolean))].sort()
allGrades.value = grades
allClasses.value = classes
}
} catch (err) {
console.error('获取所有学生数据失败:', err)
}
}
// 监听显示状态
watch(
() => props.show,
(newVal) => {
if (newVal) {
fetchAllStudents()
// 重置状态
selectedUserIds.value = []
excelPreviewData.value = []
targetStatus.value = ''
statusReason.value = ''
error.value = ''
}
}
)
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4a5568;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #718096;
}
</style>
@@ -0,0 +1,575 @@
<template>
<div
class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2 animate-in fade-in slide-in-from-bottom-4 duration-700"
>
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
歌曲黑名单管理
</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
</p>
</div>
</div>
<!-- 添加黑名单项表单 -->
<section class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-8 shadow-xl">
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mb-6 px-1">
添加黑名单项
</h3>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
<div class="xl:col-span-2 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁类型</label
>
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg">
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'SONG' ? 'bg-zinc-800 text-blue-400' : 'text-zinc-600'"
@click="newItem.type = 'SONG'"
>
<Music :size="12" /> 具体歌曲
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'KEYWORD' ? 'bg-zinc-800 text-purple-400' : 'text-zinc-600'"
@click="newItem.type = 'KEYWORD'"
>
<Type :size="12" /> 关键词
</button>
</div>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
{{ newItem.type === 'SONG' ? '歌曲名称 - 艺术家' : '关键词内容' }}
</label>
<input
v-model="newItem.value"
type="text"
:placeholder="
newItem.type === 'SONG' ? '例如: 歌曲名 - 艺术家' : '例如: 敏感词、特定风格'
"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁原因 (可选)</label
>
<input
v-model="newItem.reason"
type="text"
placeholder="简述加入黑名单的原因"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
>
</div>
<div class="xl:col-span-2">
<button
:disabled="!newItem.value || loading"
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="addBlacklistItem"
>
<Plus v-if="!loading" :size="16" />
<div
v-else
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
{{ loading ? '处理中...' : '添加到黑名单' }}
</button>
</div>
</div>
</section>
<!-- 筛选和搜索 -->
<div
class="bg-zinc-900/20 border border-zinc-800 rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
>
<div class="flex-1 relative w-full">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700" :size="18" />
<input
v-model="filters.search"
type="text"
placeholder="在黑名单中搜索..."
class="w-full bg-zinc-950/50 border border-zinc-800 rounded-lg pl-12 pr-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
@input="debounceSearch"
>
</div>
<div class="flex gap-3 w-full md:w-auto">
<CustomSelect
label="类型筛选"
:value="typeFilterLabel"
:options="['全部类型', '具体歌曲', '关键词']"
class="flex-1 md:w-48"
@change="handleTypeFilterChange"
/>
<button
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-600 hover:text-blue-400 transition-all active:scale-95"
@click="loadBlacklist"
>
<Filter :size="18" />
</button>
</div>
</div>
<!-- 黑名单列表 -->
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
黑名单列表 ({{ pagination.total }} 项)
</h4>
</div>
<div class="grid grid-cols-1 gap-3">
<TransitionGroup
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in absolute w-full"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-for="item in blacklist"
:key="item.id"
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-zinc-900/30 border rounded-xl transition-all hover:border-zinc-700"
:class="[item.isActive ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60']"
>
<div class="flex-1 flex items-start gap-5">
<div
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
:class="[
item.type === 'SONG'
? 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
: 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
]"
>
<Music v-if="item.type === 'SONG'" :size="22" />
<Type v-else :size="22" />
</div>
<div class="space-y-1.5 min-w-0">
<div class="flex items-center gap-3">
<span
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
:class="[
item.type === 'SONG'
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
: 'bg-purple-600/10 text-purple-400 border-purple-500/20'
]"
>
{{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
</span>
<h5 class="text-base font-black text-zinc-100 truncate tracking-tight">
{{ item.value }}
</h5>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2 text-zinc-500 font-bold text-[11px]">
<MessageSquare :size="12" class="text-zinc-700" />
原因: {{ item.reason || '未填写原因' }}
</div>
<div
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
>
<Clock :size="11" class="text-zinc-800" />
创建时间: {{ formatDate(item.createdAt) }}
</div>
</div>
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-800/50"
>
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
:class="[
item.isActive
? 'bg-zinc-800 text-zinc-500 hover:text-amber-500 hover:bg-amber-500/5 hover:border-amber-500/10 border border-transparent'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="toggleItemStatus(item)"
>
<Power :size="12" />
{{ item.isActive ? '禁用' : '启用' }}
</button>
<button
:disabled="loading"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-700 hover:text-red-500 hover:border-red-500/30 transition-all active:scale-95"
@click="deleteItem(item)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
</TransitionGroup>
<!-- 加载状态 -->
<div
v-if="loading && blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center space-y-4"
>
<div
class="w-12 h-12 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin"
/>
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
</div>
<!-- 空状态 -->
<div
v-else-if="blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-zinc-900/10 border border-zinc-800/40 border-dashed rounded-xl"
>
<div
class="w-16 h-16 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-800 shadow-xl"
>
<Ban :size="32" />
</div>
<div class="space-y-1 px-4">
<h6 class="text-sm font-bold text-zinc-600">未找到匹配的黑名单项</h6>
<p class="text-[10px] text-zinc-700 font-bold uppercase tracking-widest">
请尝试调整筛选条件或添加新项目
</p>
</div>
</div>
</div>
<!-- 分页控制 -->
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
<button
:disabled="pagination.page <= 1 || loading"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page - 1)"
>
<ChevronLeft :size="20" />
</button>
<div
class="px-6 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-[11px] font-black text-zinc-400 uppercase tracking-widest"
>
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
</div>
<button
:disabled="pagination.page >= pagination.pages || loading"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page + 1)"
>
<ChevronRight :size="20" />
</button>
</div>
</div>
<!-- 删除确认模态框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showDeleteDialog"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
@click.stop
>
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
<div
class="w-16 h-16 rounded-lg bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-2xl shadow-red-900/10"
>
<Trash2 :size="28" />
</div>
<div class="space-y-2">
<h4 class="text-lg font-bold text-zinc-100">确认从黑名单移除</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
确定要移除
<span class="text-zinc-300 font-bold">"{{ deleteTargetItem?.value }}"</span>
吗?移除后,该项目将恢复为正常状态。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
@click="showDeleteDialog = false"
>
取消
</button>
<button
:disabled="loading"
class="flex-1 px-4 py-4 bg-red-600 hover:bg-red-500 text-white text-[10px] font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
@click="confirmDelete"
>
{{ loading ? '删除中...' : '确认移除' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import {
ShieldAlert,
Music,
Type,
Plus,
Search,
Filter,
MessageSquare,
Clock,
Power,
Trash2,
Ban,
ChevronLeft,
ChevronRight,
ChevronDown
} from 'lucide-vue-next'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const { showToast: showNotification } = useToast()
const blacklist = ref([])
const loading = ref(false)
const error = ref('')
const success = ref('')
// 删除对话框相关
const showDeleteDialog = ref(false)
const deleteTargetItem = ref(null)
const newItem = reactive({
type: 'SONG',
value: '',
reason: ''
})
const filters = reactive({
type: '',
search: ''
})
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
pages: 0
})
const typeFilterLabel = computed(() => {
if (filters.type === 'SONG') return '具体歌曲'
if (filters.type === 'KEYWORD') return '关键词'
return '全部类型'
})
let searchTimeout = null
// 加载黑名单
const loadBlacklist = async () => {
loading.value = true
error.value = ''
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
...(filters.type && { type: filters.type }),
...(filters.search && { search: filters.search })
})
const response = await $fetch(`/api/admin/blacklist?${params}`, {
...useAuth().getAuthConfig()
})
blacklist.value = response.blacklist
Object.assign(pagination, response.pagination)
} catch (err) {
error.value = '获取黑名单失败'
console.error('获取黑名单失败:', err)
showNotification('获取黑名单失败: ' + (err.data?.message || err.message), 'error')
} finally {
loading.value = false
}
}
// 处理类型筛选变更
const handleTypeFilterChange = (label) => {
if (label === '具体歌曲') filters.type = 'SONG'
else if (label === '关键词') filters.type = 'KEYWORD'
else filters.type = ''
pagination.page = 1
loadBlacklist()
}
// 添加黑名单项
const addBlacklistItem = async () => {
if (!newItem.value.trim()) return
loading.value = true
error.value = ''
success.value = ''
try {
await $fetch('/api/admin/blacklist', {
method: 'POST',
body: {
type: newItem.type,
value: newItem.value.trim(),
reason: newItem.reason.trim() || null
},
...useAuth().getAuthConfig()
})
showNotification('黑名单项添加成功', 'success')
// 重置表单
newItem.value = ''
newItem.reason = ''
// 重新加载列表
pagination.page = 1
await loadBlacklist()
} catch (err) {
error.value = err.data?.message || '添加黑名单项失败'
console.error('添加黑名单项失败:', err)
showNotification('添加失败: ' + error.value, 'error')
} finally {
loading.value = false
}
}
// 切换项目状态
const toggleItemStatus = async (item) => {
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${item.id}`, {
method: 'PATCH',
body: {
isActive: !item.isActive
},
...useAuth().getAuthConfig()
})
item.isActive = !item.isActive
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
} catch (err) {
error.value = '更新状态失败'
console.error('更新状态失败:', err)
showNotification('更新状态失败', 'error')
} finally {
loading.value = false
}
}
// 删除项目
const deleteItem = (item) => {
deleteTargetItem.value = item
showDeleteDialog.value = true
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetItem.value) return
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${deleteTargetItem.value.id}`, {
method: 'DELETE',
...useAuth().getAuthConfig()
})
showNotification('黑名单项删除成功', 'success')
await loadBlacklist()
} catch (err) {
error.value = '删除失败'
console.error('删除失败:', err)
showNotification('删除失败: ' + err.message, 'error')
} finally {
loading.value = false
showDeleteDialog.value = false
deleteTargetItem.value = null
}
}
// 搜索防抖
const debounceSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadBlacklist()
}, 500)
}
// 切换页面
const changePage = (page) => {
pagination.page = page
loadBlacklist()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
onMounted(() => {
loadBlacklist()
})
</script>
<style scoped>
.animate-in {
animation-duration: 0.5s;
animation-fill-mode: both;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* 隐藏滚动条但保持功能 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,883 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<div class="space-y-1">
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">数据库操作</h2>
<p class="text-xs text-zinc-500 font-medium">
执行系统底层维护任务,包括备份、恢复及全局数据重置
</p>
</div>
<!-- 操作卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="card in cards"
:key="card.id"
:class="[
'group relative bg-zinc-900/40 border border-zinc-800 rounded-2xl p-8 transition-all hover:border-zinc-700 hover:shadow-2xl hover:shadow-black/40',
card.isDanger ? 'hover:border-rose-500/20' : ''
]"
>
<div class="flex flex-col h-full space-y-6">
<div class="flex items-center justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
card.isDanger
? 'text-rose-500 border-rose-500/10'
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
]"
>
<component :is="card.icon" class="w-6 h-6" />
</div>
<span
v-if="card.isDanger"
class="px-2 py-0.5 bg-rose-500/10 text-rose-500 text-[9px] font-black uppercase tracking-widest border border-rose-500/20 rounded"
>高风险操作</span
>
</div>
<div class="flex-1 space-y-2">
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
{{ card.title }}
</h3>
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
{{ card.desc }}
</p>
</div>
<button
:disabled="isLoading(card.id)"
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-zinc-950 border border-zinc-800 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 hover:border-zinc-700"
:class="
card.isDanger
? 'bg-zinc-950 border border-rose-900/30 text-rose-500 hover:bg-rose-600 hover:text-white hover:border-rose-600 shadow-lg shadow-rose-900/5'
: ''
"
@click="openModal(card.id)"
>
<span v-if="isLoading(card.id)">执行中...</span>
<span v-else>{{ card.btnText }}</span>
</button>
</div>
<!-- 背景装饰 -->
<div
:class="[
'absolute -right-4 -bottom-4 w-32 h-32 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity rounded-full pointer-events-none',
card.isDanger ? 'bg-rose-500/5' : `bg-${card.color}-500/5`
]"
/>
</div>
</div>
<!-- 维护建议 -->
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
<AlertCircle class="text-blue-500 shrink-0 mt-0.5 w-[18px] h-[18px]" />
<div class="space-y-1">
<p class="text-[11px] font-bold text-zinc-300">数据库维护建议</p>
<p class="text-[10px] text-zinc-500 leading-relaxed">
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
</p>
</div>
</div>
<!-- 创建备份模态框 -->
<div
v-if="activeModal === 'backup'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">创建数据库备份</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
选择包含的内容
</p>
<div class="space-y-2">
<label
v-for="(item, i) in backupOptions"
:key="i"
class="flex items-start gap-4 p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-700 transition-all group"
>
<div class="shrink-0 mt-0.5">
<input
v-model="createForm[item.key]"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
>
</div>
<div>
<p
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ item.label }}
</p>
<p class="text-[10px] text-zinc-600 font-medium mt-0.5">{{ item.desc }}</p>
</div>
</label>
</div>
</div>
<div class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl">
<p class="text-[10px] text-zinc-500 text-center italic">
备份文件将以 .json 格式生成并自动下载
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="createLoading"
class="px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="createBackup"
>
{{ createLoading ? '正在导出...' : '开始导出' }}
</button>
</div>
</div>
</div>
<!-- 恢复备份模态框 -->
<div
v-if="activeModal === 'restore'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">恢复数据库备份</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div
class="border-2 border-dashed border-zinc-800 rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-emerald-500/50 hover:bg-emerald-500/5 transition-all cursor-pointer"
@click="$refs.fileInput.click()"
@dragover.prevent
@drop.prevent="handleFileDrop"
>
<Upload
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
/>
<h5 class="text-sm font-bold text-zinc-300">
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
</h5>
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
仅支持 VoiceHub 导出的 .json 格式
</p>
<input
ref="fileInput"
accept=".json"
class="hidden"
type="file"
@change="handleFileSelect"
>
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>恢复模式</label
>
<div class="grid grid-cols-2 gap-3">
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'merge'
? 'bg-zinc-950 border-emerald-500/30'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="restoreForm.mode = 'merge'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
增量模式
</h6>
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">仅导入不重复的新记录</p>
</button>
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'replace'
? 'bg-zinc-950 border-emerald-500/30'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="restoreForm.mode = 'replace'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
覆盖模式
</h6>
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">清空现有表后完整恢复</p>
</button>
</div>
</div>
<div
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
>
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="restoreForm.overwriteSuperAdmin"
type="checkbox"
class="mt-0.5 accent-emerald-500"
>
<div>
<p class="text-xs font-bold text-zinc-200">覆盖备份中的超级管理员账号数据</p>
<p class="text-[10px] text-zinc-500 mt-1">
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
</p>
</div>
</label>
</div>
<div
class="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-amber-500 shrink-0 mt-0.5 w-4 h-4" />
<p class="text-[10px] text-zinc-500 leading-normal font-medium">
注意:覆盖模式将永久清空当前数据库中对应的表内容。此操作将导致现有会话中断。
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="uploadLoading || !selectedFile"
class="px-8 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="restoreBackup"
>
{{ uploadLoading ? restoreProgress || '正在恢复...' : '确认并开始恢复' }}
</button>
</div>
</div>
</div>
<!-- 重置序列模态框 -->
<div
v-if="activeModal === 'reset-seq'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置数据表序列</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择目标表</label
>
<CustomSelect
v-model="sequenceForm.table"
:options="tableOptions"
class="w-full"
/>
</div>
<div class="p-6 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-4">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
>
<AlertCircle class="w-4 h-4" />
</div>
<h6 class="text-xs font-bold text-zinc-300 uppercase tracking-widest">
什么是重置序列?
</h6>
</div>
<p class="text-[11px] text-zinc-500 leading-relaxed font-medium">
如果您的数据表 ID
出现了断档或在手动操作数据库后无法自增,重置序列可以将数据库底层的计数器更新为当前 ID
最大值 +1,从而解决 ID 冲突导致的写入失败问题。此操作不会修改任何现有数据。
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="sequenceLoading || !sequenceForm.table"
class="px-8 py-2 bg-amber-600 hover:bg-amber-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetSequence"
>
{{ sequenceLoading ? '正在重置...' : '执行重置' }}
</button>
</div>
</div>
</div>
<!-- 重置数据库模态框 -->
<div
v-if="activeModal === 'reset-db'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-rose-500 tracking-tight">危险操作:重置数据库</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
>
<Trash2 class="text-rose-500 mb-4 w-12 h-12" />
<h4 class="text-lg font-black text-rose-500 tracking-tight">
您正在执行极其危险的操作!
</h4>
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
重置操作将永久删除系统中的所有
<span class="text-zinc-300 font-bold"
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
>。
</p>
</div>
<div class="space-y-3">
<label
class="text-[11px] font-black text-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
>
请输入以下代码以确认操作
</label>
<div
class="bg-zinc-950 border border-rose-900/30 rounded-xl px-4 py-3 font-mono text-[10px] text-rose-400 text-center select-all"
>
{{ CONFIRM_CODE }}
</div>
<input
v-model="resetConfirmText"
type="text"
placeholder="在此输入上述代码..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-rose-500/40 text-center font-mono placeholder:text-zinc-700"
>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<button
class="py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-zinc-800 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-700"
:class="
resetConfirmText === CONFIRM_CODE
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
: ''
"
@click="resetDatabase"
>
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from 'lucide-vue-next'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { useToast } from '~/composables/useToast'
import { useAuth } from '~/composables/useAuth'
const { showToast: showNotification } = useToast()
const auth = useAuth()
// 状态
const activeModal = ref('none')
const createLoading = ref(false)
const uploadLoading = ref(false)
const sequenceLoading = ref(false)
const resetLoading = ref(false)
const selectedFile = ref(null)
const resetConfirmText = ref('')
const restoreProgress = ref('')
const hasSuperAdminInBackup = ref(false)
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
// 卡片配置
const cards = [
{
id: 'backup',
title: '创建备份',
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
icon: Download,
color: 'blue',
btnText: '创建备份文件'
},
{
id: 'restore',
title: '恢复备份',
desc: '从之前导出的备份文件中恢复系统数据。',
icon: Upload,
color: 'emerald',
btnText: '选择备份文件'
},
{
id: 'reset-seq',
title: '重置序列',
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
icon: RotateCw,
color: 'amber',
btnText: '开始重置序列'
},
{
id: 'reset-db',
title: '重置数据库',
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
icon: Trash2,
color: 'rose',
btnText: '立即重置数据库',
isDanger: true
}
]
// 备份选项
const backupOptions = [
{
key: 'includeSongs',
label: '歌曲与排期数据',
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
},
{
key: 'includeSystemData',
label: '系统配置信息',
desc: '包含站点设置、黑名单、播出时段等全局参数'
},
{
key: 'includeUsers',
label: '用户账户数据',
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
}
]
// 表单数据
const createForm = ref({
includeSongs: true,
includeUsers: true,
includeSystemData: true
})
const restoreForm = ref({
mode: 'merge',
overwriteSuperAdmin: false
})
const sequenceForm = ref({
table: '重置所有表 (All)'
})
const tableOptions = [
'重置所有表 (All)',
'歌曲表 (Song)',
'用户表 (User)',
'投票表 (Vote)',
'排期表 (Schedule)',
'通知表 (Notification)',
'通知设置表 (NotificationSettings)',
'播放时段表 (PlayTime)',
'学期表 (Semester)',
'系统设置表 (SystemSettings)',
'歌曲黑名单表 (SongBlacklist)'
]
const labelToValueMap = {
'重置所有表 (All)': 'all',
'歌曲表 (Song)': 'Song',
'用户表 (User)': 'User',
'投票表 (Vote)': 'Vote',
'排期表 (Schedule)': 'Schedule',
'通知表 (Notification)': 'Notification',
'通知设置表 (NotificationSettings)': 'NotificationSettings',
'播放时段表 (PlayTime)': 'PlayTime',
'学期表 (Semester)': 'Semester',
'系统设置表 (SystemSettings)': 'SystemSettings',
'歌曲黑名单表 (SongBlacklist)': 'SongBlacklist'
}
// 辅助函数
const isLoading = (id) => {
if (id === 'backup') return createLoading.value
if (id === 'restore') return uploadLoading.value
if (id === 'reset-seq') return sequenceLoading.value
if (id === 'reset-db') return resetLoading.value
return false
}
const openModal = (id) => {
activeModal.value = id
if (id === 'reset-db') {
resetConfirmText.value = ''
}
}
// 文件处理
const parseBackupSuperAdmin = async (file) => {
try {
const fileContent = await file.text()
const backupData = JSON.parse(fileContent)
const users = Array.isArray(backupData?.data?.users) ? backupData.data.users : []
hasSuperAdminInBackup.value = users.some((item) => item?.role === 'SUPER_ADMIN')
if (!hasSuperAdminInBackup.value) {
restoreForm.value.overwriteSuperAdmin = false
}
} catch (error) {
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
console.error('解析备份文件失败:', error)
}
}
const handleFileSelect = async (event) => {
const file = event.target.files[0]
if (file && file.type === 'application/json') {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification('请选择有效的JSON备份文件', 'error')
}
}
const handleFileDrop = async (event) => {
const file = event.dataTransfer.files[0]
if (file && file.type === 'application/json') {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification('请选择有效的JSON备份文件', 'error')
}
}
// API 操作
const createBackup = async () => {
createLoading.value = true
try {
let tables = 'all'
if (createForm.value.includeUsers && createForm.value.includeSongs) {
tables = 'all'
} else if (createForm.value.includeUsers) {
tables = 'users'
} else if (createForm.value.includeSongs) {
tables = 'songs'
} else if (createForm.value.includeSystemData) {
tables = ['systemSettings']
} else {
throw new Error('请至少选择一项备份内容')
}
const response = await $fetch('/api/admin/backup/export', {
method: 'POST',
body: {
tables,
includeSystemData: createForm.value.includeSystemData
}
})
if (response.success && response.backup) {
if (response.backup.downloadMode === 'direct' && response.backup.data) {
const blob = new Blob([JSON.stringify(response.backup.data, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = response.backup.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
const downloadResponse = await fetch(downloadUrl, {
method: 'GET',
credentials: 'include'
})
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
const blob = await downloadResponse.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = response.backup.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
}
} else {
throw new Error(response.message || '备份创建失败')
}
} catch (error) {
console.error('创建备份失败:', error)
showNotification('创建备份失败: ' + error.message, 'error')
} finally {
createLoading.value = false
}
}
const restoreBackup = async () => {
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
uploadLoading.value = true
restoreProgress.value = '正在读取文件...'
try {
const fileContent = await selectedFile.value.text()
let backupData
try {
backupData = JSON.parse(fileContent)
} catch {
throw new Error('无法解析备份文件,文件格式不正确')
}
if (!backupData.data) throw new Error('备份文件缺少数据部分')
const fileHasSuperAdmin = hasSuperAdminInBackup.value
if (!fileHasSuperAdmin) {
restoreForm.value.overwriteSuperAdmin = false
}
let preservedSuperAdminIds = []
let temporaryPreservedUserId = null
if (restoreForm.value.mode === 'replace') {
restoreProgress.value = '正在清空现有数据...'
const clearResult = await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
}
const tableOrder = [
'users',
'userIdentities',
'systemSettings',
'semesters',
'playTimes',
'songs',
'votes',
'schedules',
'notificationSettings',
'notifications',
'songBlacklist',
'userStatusLogs'
]
const mappings = {
users: {},
songs: {},
meta: {
preservedSuperAdminIds,
temporaryPreservedUserId
}
}
const CHUNK_SIZE = 50
let totalProcessed = 0
let totalRecords = 0
for (const tableName of tableOrder) {
if (backupData.data[tableName] && Array.isArray(backupData.data[tableName])) {
totalRecords += backupData.data[tableName].length
}
}
for (const tableName of tableOrder) {
const records = backupData.data[tableName]
if (!records || !Array.isArray(records) || records.length === 0) continue
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE)
const progressPercent = Math.round((totalProcessed / totalRecords) * 100)
restoreProgress.value = `正在恢复 ${tableName} (${i + 1}-${Math.min(i + CHUNK_SIZE, records.length)}/${records.length}) ${progressPercent}%`
const response = await $fetch('/api/admin/backup/restore-chunk', {
method: 'POST',
body: {
tableName,
records: chunk,
mappings,
mode: restoreForm.value.mode,
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!response.success) throw new Error(response.message || `恢复表 ${tableName} 失败`)
if (response.newMappings) {
if (response.newMappings.users) Object.assign(mappings.users, response.newMappings.users)
if (response.newMappings.songs) Object.assign(mappings.songs, response.newMappings.songs)
}
totalProcessed += chunk.length
}
}
restoreProgress.value = '正在修复数据表序列...'
const sequenceResult = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: 'all' }
})
if (!sequenceResult.success) {
throw new Error(sequenceResult.message || sequenceResult.error || '序列修复失败')
}
restoreProgress.value = '正在重载SMTP配置...'
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!smtpReloadResult.success) {
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
}
const shouldFinalizeTempUser =
restoreForm.value.mode === 'replace' &&
restoreForm.value.overwriteSuperAdmin &&
fileHasSuperAdmin &&
temporaryPreservedUserId
if (shouldFinalizeTempUser) {
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
restoreProgress.value = '正在完成管理员替换...'
await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
finalizeTempUser: true
}
})
}
showNotification('数据库恢复成功,正在重新登录', 'success')
activeModal.value = 'none'
setTimeout(() => {
if (auth.logout) {
auth.logout()
}
localStorage.removeItem('auth-token')
localStorage.removeItem('user-info')
window.location.href = '/'
}, 1200)
return
}
showNotification('数据库恢复成功', 'success')
activeModal.value = 'none'
} catch (error) {
console.error('恢复备份失败:', error)
showNotification('恢复备份失败: ' + error.message, 'error')
} finally {
uploadLoading.value = false
restoreProgress.value = ''
}
}
const resetSequence = async () => {
if (!sequenceForm.value.table) return
sequenceLoading.value = true
try {
const tableValue = labelToValueMap[sequenceForm.value.table] || sequenceForm.value.table
const response = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: tableValue }
})
if (response.success) {
showNotification(response.message || '序列重置成功', 'success')
activeModal.value = 'none'
} else {
throw new Error(response.error || response.message || '重置失败')
}
} catch (error) {
console.error('重置序列失败:', error)
showNotification('重置序列失败: ' + error.message, 'error')
} finally {
sequenceLoading.value = false
}
}
const resetDatabase = async () => {
if (resetConfirmText.value !== CONFIRM_CODE) return
resetLoading.value = true
try {
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
if (response.success) {
showNotification('数据库已成功重置', 'success')
activeModal.value = 'none'
setTimeout(() => window.location.reload(), 1500)
} else {
throw new Error(response.message || '重置失败')
}
} catch (error) {
console.error('重置数据库失败:', error)
showNotification('重置数据库失败: ' + error.message, 'error')
} finally {
resetLoading.value = false
}
}
</script>
@@ -0,0 +1,345 @@
<template>
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] overflow-hidden">
<!-- 头部 -->
<div class="flex items-center justify-between p-6 border-b border-zinc-800 bg-zinc-900/20">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center border border-blue-500/20"
>
<Mail :size="20" class="text-blue-500" />
</div>
<div>
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">邮件模板管理</h3>
<p class="text-[10px] text-zinc-500 mt-0.5">自定义系统邮件的主题与内容,支持变量替换</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
v-if="selected"
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-[11px] font-bold rounded-xl transition-all"
:disabled="saving"
@click="doPreview"
>
<Eye :size="14" /> {{ previewHtml ? '刷新预览' : '实时预览' }}
</button>
<button
v-if="selected"
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
:disabled="saving"
@click="save"
>
<Save :size="14" /> {{ saving ? '保存中...' : '更新模板' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 min-h-[650px]">
<!-- 左侧:模板列表 -->
<div class="lg:col-span-3 border-r border-zinc-800 bg-zinc-900/10 flex flex-col">
<div class="p-4 border-b border-zinc-800/50">
<div class="relative">
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
<input
type="text"
placeholder="搜索模板..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-[10px] text-zinc-400 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
<button
v-for="t in templates"
:key="t.key"
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
:class="[
selectedKey === t.key
? 'bg-blue-600/10 border-blue-500/20'
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
]"
@click="select(t)"
>
<div class="flex items-center justify-between">
<span
class="text-[11px] font-black tracking-tight transition-colors"
:class="
selectedKey === t.key
? 'text-blue-400'
: 'text-zinc-300 group-hover:text-zinc-100'
"
>
{{ t.name }}
</span>
<div class="flex gap-1">
<span
v-if="t.isBuiltin && !t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-zinc-800 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
>
内置
</span>
<span
v-if="t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-[8px] font-black text-emerald-500 uppercase tracking-tighter"
>
自定义
</span>
</div>
</div>
<span class="text-[9px] font-bold text-zinc-600 font-mono">{{ t.key }}</span>
</button>
</div>
</div>
<!-- 右侧:编辑器区域 -->
<div class="lg:col-span-9 flex flex-col bg-zinc-950/20 overflow-hidden">
<div v-if="selected" class="flex-1 overflow-y-auto custom-scrollbar p-8">
<div class="max-w-4xl mx-auto space-y-8">
<!-- 基础信息与主题 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>模板名称</label
>
<input
v-model="form.name"
type="text"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between px-1">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>邮件主题</label
>
<span class="text-[9px] text-zinc-500 font-bold uppercase">支持变量</span>
</div>
<input
v-model="form.subject"
type="text"
placeholder="请输入邮件主题..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
<!-- 变量提示 -->
<div class="bg-blue-600/5 border border-blue-500/10 rounded-2xl p-4 flex gap-4">
<div
class="w-8 h-8 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0"
>
<Info :size="16" class="text-blue-500" />
</div>
<div class="space-y-1">
<h4 class="text-[10px] font-black text-blue-400 uppercase tracking-widest">
可用变量
</h4>
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
<template v-if="selected.key === 'verification.code'">
<span v-pre>{{ name }}, {{ email }}, {{ code }}, {{ expiresInMinutes }}</span>
</template>
<template v-else>
<span v-pre>{{ title }}, {{ message }}, {{ actionUrl }}, {{ fromName }}</span>
</template>
</p>
</div>
</div>
<!-- 内容编辑器 -->
<div class="space-y-3">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>HTML 内容</label
>
<span
class="px-2 py-0.5 rounded-md bg-zinc-900 text-[8px] font-black text-zinc-500 uppercase"
>Handlebars</span
>
</div>
<button
v-if="selected.isOverridden"
class="flex items-center gap-1.5 text-[10px] font-bold text-rose-500 hover:text-rose-400 transition-colors"
@click="restore"
>
<RotateCcw :size="12" /> 恢复默认配置
</button>
</div>
<div class="relative group">
<div
class="absolute right-4 top-4 p-2 rounded-lg bg-zinc-900/50 border border-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Code :size="14" class="text-zinc-500" />
</div>
<textarea
v-model="form.html"
rows="16"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl p-6 text-[11px] text-zinc-300 font-mono leading-relaxed focus:outline-none focus:border-blue-500/30 custom-scrollbar"
placeholder="<html>..."
/>
</div>
</div>
<!-- 预览框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
>
<div v-if="previewHtml" class="space-y-4 pt-4 border-t border-zinc-800">
<div class="flex items-center justify-between px-1">
<label
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
>
<Eye :size="14" /> 实时预览:
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
</label>
<button
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300"
@click="previewHtml = ''"
>
隐藏预览
</button>
</div>
<div class="rounded-2xl overflow-hidden border border-zinc-800 bg-white shadow-2xl">
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
</div>
</div>
</Transition>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="flex-1 flex flex-col items-center justify-center text-zinc-700 space-y-4"
>
<div
class="w-16 h-16 rounded-3xl bg-zinc-900/50 flex items-center justify-center border border-zinc-800/50"
>
<Mail :size="32" class="text-zinc-800" />
</div>
<p class="text-xs font-bold tracking-widest uppercase">请在左侧选择一个邮件模板</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from 'lucide-vue-next'
type TemplateItem = {
key: string
name: string
subject: string
html: string
isBuiltin: boolean
isOverridden?: boolean
}
const { success, error } = useToast()
const templates = ref<TemplateItem[]>([])
const selectedKey = ref<string>('')
const form = ref<{ key: string; name: string; subject: string; html: string }>({
key: '',
name: '',
subject: '',
html: ''
})
const saving = ref(false)
const previewHtml = ref<string>('')
const previewSubject = ref<string>('')
const selected = computed(() => templates.value.find((t) => t.key === selectedKey.value))
async function loadList() {
const res: any = await $fetch('/api/admin/email-templates')
const list = Array.isArray(res?.templates) ? res.templates : []
templates.value = list.map((t: any) => ({
key: String(t.key),
name: String(t.name),
subject: String(t.subject),
html: String(t.html),
isBuiltin: !!t.isBuiltin,
isOverridden: !!t.isOverridden
}))
if (!selectedKey.value && templates.value.length) select(templates.value[0])
}
function select(t: TemplateItem) {
selectedKey.value = t.key
form.value = { key: t.key, name: t.name, subject: t.subject, html: t.html }
previewHtml.value = ''
previewSubject.value = ''
}
async function save() {
try {
saving.value = true
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
success('模板已保存')
await loadList()
} catch (e: any) {
error(e?.data?.message || '保存失败')
} finally {
saving.value = false
}
}
async function restore() {
try {
saving.value = true
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
method: 'DELETE'
})
success('已恢复默认模板')
await loadList()
} catch (e: any) {
error(e?.data?.message || '恢复失败')
} finally {
saving.value = false
}
}
async function doPreview() {
try {
const defaultData =
form.value.key === 'verification.code'
? { name: '张三', email: 'example@school.edu', code: '123456', expiresInMinutes: 5 }
: {
title: '系统通知',
message: '这是一条预览内容\n支持换行与链接',
actionUrl: 'https://example.com'
}
const res = await $fetch('/api/admin/email-templates/preview', {
method: 'POST',
body: { key: form.value.key, data: defaultData }
})
previewHtml.value = res.html
previewSubject.value = res.subject
} catch (e: any) {
error(e?.data?.message || '预览失败')
}
}
onMounted(() => {
loadList()
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
@@ -0,0 +1,759 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">向用户发送通知</h2>
<p class="text-xs text-zinc-500 mt-1">即时推送系统公告、活动信息或重要提醒至指定群体</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<!-- 左侧:编辑区 -->
<div class="lg:col-span-7 space-y-6">
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl">
<div class="space-y-6">
<!-- 标题 -->
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知标题</label
>
<input
v-model="form.title"
type="text"
placeholder="请输入通知标题"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800"
>
</div>
<!-- 内容 -->
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知内容</label
>
<textarea
v-model="form.content"
placeholder="请输入通知详情内容..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800 min-h-[160px] resize-none"
/>
</div>
<!-- 范围选择 -->
<div class="space-y-3 pt-4 border-t border-zinc-800/50">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知范围</label
>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in targetOptions"
:key="opt.id"
:class="[
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
form.scope === opt.id
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-950 border-zinc-800 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300'
]"
@click="form.scope = opt.id"
>
<component :is="opt.icon" :size="14" />
{{ opt.label }}
</button>
</div>
</div>
<!-- 动态配置区 -->
<div class="pt-2">
<Transition name="fade" mode="out-in">
<div
v-if="form.scope === 'ALL'"
key="all"
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-2xl flex items-center gap-4"
>
<Info class="text-blue-500 shrink-0" :size="18" />
<p class="text-[11px] font-bold text-zinc-400">
将向系统中所有已注册的活跃用户发送此通知。
</p>
</div>
<div v-else-if="form.scope === 'GRADE'" key="grade" class="space-y-4">
<div class="space-y-1.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择年级</span
>
<CustomSelect
v-model="form.grade"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full md:w-64"
/>
</div>
</div>
<div
v-else-if="form.scope === 'CLASS'"
key="class"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>年级</span
>
<CustomSelect
v-model="form.classGrade"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full"
/>
</div>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>班级</span
>
<input
v-model="form.className"
type="text"
placeholder="如: 1班、2班"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200"
>
</div>
</div>
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
<div class="p-6 bg-zinc-950/50 border border-zinc-800 border-dashed rounded-2xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<CustomSelect
v-model="multiClassForm.grade"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full"
/>
<div class="flex gap-2">
<input
v-model="multiClassForm.class"
type="text"
placeholder="输入班级名称"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-xs focus:outline-none text-zinc-200"
>
<button
:disabled="!canAddClass"
class="px-4 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 font-bold rounded-xl text-xs transition-all"
@click="addClassToSelection"
>
添加
</button>
</div>
</div>
<div v-if="form.selectedClasses.length > 0" class="flex flex-wrap gap-2">
<div
v-for="(cls, index) in form.selectedClasses"
:key="index"
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
>
<span class="text-[10px] font-bold text-zinc-400"
>{{ cls.grade }} {{ cls.class }}</span
>
<button
class="text-zinc-600 hover:text-red-400 transition-colors"
@click="removeClassFromSelection(index)"
>
<X :size="12" />
</button>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
未选择任何班级
</p>
</div>
</div>
</div>
<div v-else-if="form.scope === 'SPECIFIC_USERS'" key="specific" class="space-y-4">
<div class="relative flex items-center">
<Search
v-if="!userSearchLoading"
class="absolute left-4 text-zinc-700 pointer-events-none"
:size="16"
/>
<Loader2
v-else
class="absolute left-4 text-blue-500 animate-spin pointer-events-none"
:size="16"
/>
<input
v-model="userSearchQuery"
type="text"
placeholder="搜索并选择用户 (姓名、用户名或ID)..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
@input="onUserSearchInput"
>
</div>
<!-- 搜索结果 -->
<div
v-if="showUserSearchResults && userSearchResults.length > 0"
class="bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
>
<div class="px-4 py-2 border-b border-zinc-800/60 bg-zinc-900/40">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>搜索结果 ({{ userSearchResults.length }})</span
>
</div>
<div
v-for="user in userSearchResults"
:key="user.id"
class="flex items-center justify-between p-4 border-b border-zinc-800/30 last:border-0 hover:bg-zinc-800/30 transition-all"
>
<div>
<h5 class="text-sm font-bold text-zinc-200">
{{ user.name || user.username }}
</h5>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] text-blue-500 font-black"
>@{{ user.username }}</span
>
<span
v-if="user.grade && user.class"
class="text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
>{{ user.grade }} {{ user.class }}</span
>
<span class="text-[10px] text-zinc-700 font-black">{{
getRoleText(user.role)
}}</span>
</div>
</div>
<button
:disabled="isUserSelected(user.id)"
class="px-3 py-1.5 bg-zinc-800 hover:bg-blue-600 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase"
@click="addUserToSelection(user)"
>
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
</button>
</div>
</div>
<!-- 已选择的用户 -->
<div v-if="form.selectedUsers.length > 0" class="space-y-3">
<div class="flex items-center justify-between px-1">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>已选择用户 ({{ form.selectedUsers.length }})</span
>
<button
class="text-[9px] font-black text-red-500/70 hover:text-red-500 uppercase tracking-widest transition-colors"
@click="clearAllSelectedUsers"
>
清空全部
</button>
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="(user, index) in form.selectedUsers"
:key="user.id"
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
>
<div class="flex flex-col">
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
user.name || user.username
}}</span>
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
</div>
<button
class="text-zinc-600 hover:text-red-400 transition-colors"
@click="removeUserFromSelection(index)"
>
<X :size="12" />
</button>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
未选择任何用户
</p>
</div>
</div>
</Transition>
</div>
</div>
<!-- 错误和成功提示 -->
<div v-if="error || success" class="mt-6">
<div
v-if="error"
class="p-4 bg-red-500/5 border border-red-500/10 rounded-2xl flex items-center gap-3"
>
<AlertCircle class="text-red-500 shrink-0" :size="18" />
<p class="text-xs font-bold text-red-400">{{ error }}</p>
</div>
<div
v-if="success"
class="p-4 bg-green-500/5 border border-green-500/10 rounded-2xl flex items-center gap-3"
>
<Check class="text-green-500 shrink-0" :size="18" />
<p class="text-xs font-bold text-green-400">{{ success }}</p>
</div>
</div>
<div class="mt-10 flex justify-end">
<button
:disabled="loading || !isFormValid"
:class="[
'flex items-center gap-2 px-10 py-4 bg-zinc-950 border border-zinc-800 text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
loading || !isFormValid
? 'text-zinc-800 cursor-not-allowed'
: 'text-zinc-400 hover:text-zinc-100 hover:border-zinc-600 hover:bg-zinc-900'
]"
@click="sendNotification"
>
<Loader2 v-if="loading" class="animate-spin" :size="16" />
<Send v-else :size="16" />
{{ loading ? '发送中...' : '发送通知' }}
</button>
</div>
</div>
</div>
<!-- 右侧:预览区 -->
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
<div
class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
>
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest mb-6 flex items-center gap-2"
>
<Eye :size="16" class="text-blue-500" /> 通知预览
</h3>
<div class="flex-1 flex flex-col items-center justify-center p-4">
<div
class="w-full max-w-[320px] bg-[#0c0c0e] border border-zinc-800 rounded-3xl overflow-hidden shadow-2xl relative"
>
<!-- 顶部装饰 -->
<div class="h-1.5 bg-blue-600 w-full" />
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-xl bg-blue-600/10 text-blue-500 flex items-center justify-center border border-blue-500/20"
>
<Bell :size="14" />
</div>
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>VoiceHub 系统</span
>
</div>
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
>刚刚</span
>
</div>
<div class="space-y-2">
<h4
:class="[
'text-sm font-black transition-colors',
form.title ? 'text-zinc-100' : 'text-zinc-800 italic'
]"
>
{{ form.title || '通知标题' }}
</h4>
<p
:class="[
'text-[11px] leading-relaxed transition-colors',
form.content ? 'text-zinc-400' : 'text-zinc-800 italic line-clamp-3'
]"
>
{{ form.content || '这里将显示通知的详细内容预览...' }}
</p>
</div>
<div class="pt-4 border-t border-zinc-800/50 flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Users :size="12" class="text-zinc-700" />
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-wider">
发送范围: {{ scopeDescription }}
</span>
</div>
<button
class="p-1.5 text-blue-500 hover:bg-blue-600/10 rounded-lg transition-all"
>
<MessageSquare :size="14" />
</button>
</div>
</div>
<!-- 背景光晕 -->
<div
class="absolute top-0 right-0 w-32 h-32 bg-blue-600/5 blur-[50px] -z-10 rounded-full"
/>
</div>
<div class="mt-8 space-y-3 w-full max-w-[320px]">
<div
class="flex items-start gap-3 p-3 bg-amber-500/5 border border-amber-500/10 rounded-2xl"
>
<AlertCircle class="text-amber-500 shrink-0 mt-0.5" :size="14" />
<p class="text-[10px] font-bold text-zinc-500 leading-normal">
预览图仅供参考布局,实际发送效果可能会根据用户设备显示语言和屏幕尺寸有所不同。
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, onUnmounted } from 'vue'
import {
Send,
Users,
GraduationCap,
LayoutGrid,
User,
Search,
Bell,
Info,
X,
Check,
Plus,
AlertCircle,
Eye,
MessageSquare,
Loader2
} from 'lucide-vue-next'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { useAuth } from '~/composables/useAuth'
import { useAdmin } from '~/composables/useAdmin'
const { isAdmin } = useAuth()
const { sendAdminNotification } = useAdmin()
// 表单数据
const form = ref({
title: '',
content: '',
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
grade: '',
classGrade: '',
className: '',
selectedClasses: [], // 用于多班级选择
selectedUsers: [] // 用于指定用户选择
})
// 多班级选择表单
const multiClassForm = ref({
grade: '',
class: ''
})
const loading = ref(false)
const error = ref('')
const success = ref('')
// 用户搜索相关
const userSearchQuery = ref('')
const userSearchResults = ref([])
const showUserSearchResults = ref(false)
const userSearchLoading = ref(false)
let userSearchTimeout = null
const targetOptions = [
{ id: 'ALL', label: '全体用户', icon: Users },
{ id: 'GRADE', label: '按年级选择', icon: GraduationCap },
{ id: 'CLASS', label: '按班级选择', icon: LayoutGrid },
{ id: 'MULTI_CLASS', label: '多班级选择', icon: Plus },
{ id: 'SPECIFIC_USERS', label: '指定用户', icon: User }
]
// 判断是否可以添加班级
const canAddClass = computed(() => {
return multiClassForm.value.grade && multiClassForm.value.class
})
// 添加班级到选择列表
const addClassToSelection = () => {
if (!canAddClass.value) return
// 检查是否已经选择了这个班级
const isDuplicate = form.value.selectedClasses.some(
(cls) => cls.grade === multiClassForm.value.grade && cls.class === multiClassForm.value.class
)
if (!isDuplicate) {
form.value.selectedClasses.push({
grade: multiClassForm.value.grade,
class: multiClassForm.value.class
})
// 清空输入
multiClassForm.value.class = ''
}
}
// 从选择列表中移除班级
const removeClassFromSelection = (index) => {
form.value.selectedClasses.splice(index, 1)
}
// 用户搜索输入处理(防抖)
const onUserSearchInput = () => {
clearTimeout(userSearchTimeout)
if (!userSearchQuery.value.trim()) {
userSearchResults.value = []
showUserSearchResults.value = false
return
}
userSearchTimeout = setTimeout(async () => {
await searchUsers(userSearchQuery.value.trim())
}, 300)
}
// 搜索用户API调用
const searchUsers = async (query) => {
if (!query) return
try {
userSearchLoading.value = true
const response = await $fetch('/api/admin/users', {
method: 'GET',
query: {
search: query,
limit: 20
}
})
if (response.success) {
userSearchResults.value = response.users || []
showUserSearchResults.value = true
}
} catch (err) {
console.error('搜索用户失败:', err)
userSearchResults.value = []
showUserSearchResults.value = false
} finally {
userSearchLoading.value = false
}
}
// 检查用户是否已被选择
const isUserSelected = (userId) => {
return form.value.selectedUsers.some((user) => user.id === userId)
}
// 添加用户到选择列表
const addUserToSelection = (user) => {
if (isUserSelected(user.id)) return
form.value.selectedUsers.push({
id: user.id,
name: user.name,
username: user.username,
grade: user.grade,
class: user.class,
role: user.role
})
// 清空搜索
userSearchQuery.value = ''
userSearchResults.value = []
showUserSearchResults.value = false
}
// 从选择列表中移除用户
const removeUserFromSelection = (index) => {
form.value.selectedUsers.splice(index, 1)
}
// 清空所有已选用户
const clearAllSelectedUsers = () => {
form.value.selectedUsers = []
}
// 获取角色文本
const getRoleText = (role) => {
const roleMap = {
admin: '管理员',
teacher: '教师',
student: '学生'
}
return roleMap[role] || role
}
// 表单验证
const isFormValid = computed(() => {
if (!form.value.title || !form.value.content) {
return false
}
if (form.value.scope === 'GRADE' && !form.value.grade) {
return false
}
if (form.value.scope === 'CLASS' && (!form.value.classGrade || !form.value.className)) {
return false
}
if (form.value.scope === 'MULTI_CLASS' && form.value.selectedClasses.length === 0) {
return false
}
if (form.value.scope === 'SPECIFIC_USERS' && form.value.selectedUsers.length === 0) {
return false
}
return true
})
// 范围描述
const scopeDescription = computed(() => {
switch (form.value.scope) {
case 'ALL':
return '全体用户'
case 'GRADE':
return form.value.grade ? `${form.value.grade}年级` : '请选择年级'
case 'CLASS':
return form.value.classGrade && form.value.className
? `${form.value.classGrade}年级${form.value.className}班`
: '请选择班级'
case 'MULTI_CLASS':
return form.value.selectedClasses.length > 0
? `${form.value.selectedClasses.length}个班级`
: '请选择班级'
case 'SPECIFIC_USERS':
return form.value.selectedUsers.length > 0
? `已选择${form.value.selectedUsers.length}个用户`
: '请选择用户'
default:
return ''
}
})
// 发送通知
const sendNotification = async () => {
if (!isAdmin.value) {
error.value = '只有管理员可以发送系统通知'
return
}
if (!isFormValid.value) {
error.value = '请填写完整信息'
return
}
loading.value = true
error.value = ''
success.value = ''
try {
// 构建请求数据
const notificationData = {
title: form.value.title,
content: form.value.content,
scope: form.value.scope,
filter: {}
}
// 添加过滤条件
if (form.value.scope === 'GRADE') {
notificationData.filter.grade = form.value.grade
} else if (form.value.scope === 'CLASS') {
notificationData.filter.grade = form.value.classGrade
notificationData.filter.class = form.value.className
} else if (form.value.scope === 'MULTI_CLASS') {
notificationData.filter.classes = form.value.selectedClasses
} else if (form.value.scope === 'SPECIFIC_USERS') {
notificationData.filter.userIds = form.value.selectedUsers.map((user) => user.id)
}
// 发送通知
const result = await sendAdminNotification(notificationData)
if (result && result.success) {
success.value = `成功发送通知给 ${result.sentCount} 名用户`
// 3秒后自动隐藏成功提示
setTimeout(() => {
success.value = ''
}, 3000)
// 清空表单
form.value = {
title: '',
content: '',
scope: 'ALL',
grade: '',
classGrade: '',
className: '',
selectedClasses: [],
selectedUsers: []
}
multiClassForm.value = {
grade: '',
class: ''
}
// 清空用户搜索相关状态
userSearchQuery.value = ''
userSearchResults.value = []
showUserSearchResults.value = false
userSearchLoading.value = false
clearTimeout(userSearchTimeout)
} else {
throw new Error(result?.message || '发送通知失败')
}
} catch (err) {
error.value = err.message || '发送通知时发生错误'
console.error('发送通知错误:', err)
// 3秒后自动隐藏错误提示
setTimeout(() => {
error.value = ''
}, 3000)
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (userSearchTimeout) {
clearTimeout(userSearchTimeout)
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
</style>
@@ -0,0 +1,373 @@
<template>
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<!-- 统计卡片网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
<div
v-for="(stat, i) in statCards"
:key="i"
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 relative overflow-hidden group hover:border-zinc-700 transition-all shadow-lg shadow-black/20"
>
<div class="flex justify-between items-start mb-4">
<div
:class="[
'p-3 rounded-xl border',
stat.color === 'blue'
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
: stat.color === 'emerald'
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: stat.color === 'pink'
? 'bg-pink-500/10 text-pink-500 border-pink-500/20'
: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'
]"
>
<component :is="stat.icon" :size="24" />
</div>
<div
v-if="stat.trend"
:class="[
'flex items-center gap-1 text-[11px] font-bold',
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
]"
>
<TrendingDown v-if="stat.trendDown" :size="12" />
<TrendingUp v-else :size="12" />
{{ stat.trend }}
</div>
</div>
<div>
<p class="text-zinc-500 text-sm font-medium">{{ stat.label }}</p>
<h4 class="text-3xl font-bold tracking-tight text-zinc-100">{{ stat.value }}</h4>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 最近活动 -->
<div
class="lg:col-span-5 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
>
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<Activity :size="18" class="text-blue-500" /> 最近活动
</h3>
<button
class="p-2 text-zinc-500 hover:text-zinc-300 transition-colors"
:class="{ 'animate-spin': loadingActivities }"
@click="refreshActivities"
>
<RefreshCw :size="16" />
</button>
</div>
<div
class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1 min-h-[380px] max-h-[500px]"
>
<div
v-if="loadingActivities && recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
>
<RefreshCw :size="24" class="animate-spin" />
<span class="text-sm">加载中...</span>
</div>
<div
v-else-if="recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
>
<Inbox :size="24" />
<span class="text-sm">暂无活动记录</span>
</div>
<template v-else>
<div
v-for="(activity, idx) in recentActivities"
:key="idx"
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-zinc-800/40 transition-all cursor-pointer group"
>
<div
:class="[
'shrink-0 w-10 h-10 rounded-xl flex items-center justify-center border shadow-sm',
getActivityStyle(activity.type).bg
]"
>
<component :is="getActivityStyle(activity.type).icon" :size="18" />
</div>
<div class="flex-1 min-w-0">
<h5
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ activity.title }}
</h5>
<p class="text-xs text-zinc-500 truncate mt-1">{{ activity.description }}</p>
<div class="flex items-center gap-1.5 mt-2">
<Clock :size="10" class="text-zinc-600" />
<span class="text-[10px] text-zinc-600 font-medium uppercase tracking-wider">{{
formatTime(activity.createdAt)
}}</span>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 系统状态 -->
<div
class="lg:col-span-4 bg-zinc-900/40 border border-zinc-800 rounded-xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
>
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
</h3>
<span
:class="[
'px-3 py-1 text-[10px] font-black uppercase rounded-full border',
systemStatus.online
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
]"
>
{{ systemStatus.online ? '在线' : '离线' }}
</span>
</div>
<div class="p-6 space-y-6">
<div
v-for="(status, i) in statusItems"
:key="i"
class="flex items-center justify-between group"
>
<div class="flex items-center gap-3">
<div
:class="[
'w-1.5 h-1.5 rounded-full',
status.active
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.7)]'
: 'bg-zinc-600'
]"
/>
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
</div>
<span class="text-xs font-bold text-zinc-200">{{ status.value }}</span>
</div>
</div>
</div>
<!-- 快速操作 -->
<div
class="lg:col-span-3 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
>
<div class="px-6 py-5 border-b border-zinc-800">
<h3 class="text-lg font-bold flex items-center gap-2">
<Zap :size="18" class="text-yellow-500" /> 快速操作
</h3>
</div>
<div class="p-6 space-y-3">
<button
v-for="(action, i) in quickActions"
:key="i"
:class="[
'w-full flex items-center gap-3 px-5 py-4 rounded-lg border font-bold text-sm transition-all text-left group',
action.primary
? 'bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-900/20 hover:bg-blue-500'
: 'bg-zinc-950/40 border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
]"
@click="navigateTo(action.id)"
>
<component :is="action.icon" :size="18" />
{{ action.label }}
<ExternalLink v-if="action.primary" :size="14" class="ml-auto opacity-50" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import {
Music,
Users,
Calendar,
Heart,
TrendingDown,
TrendingUp,
Activity,
ShieldCheck,
RefreshCw,
Clock,
Zap,
Bell,
Ban,
ExternalLink,
Inbox
} from 'lucide-vue-next'
import packageJson from '~~/package.json'
const emit = defineEmits(['navigate'])
const systemVersion = ref(packageJson.version)
const stats = ref({
totalSongs: 0,
totalUsers: 0,
todaySchedules: 0,
weeklyRequests: 0,
songsChange: 0,
usersChange: 0,
requestsChange: 0,
totalSchedules: 0,
currentSemester: '',
blacklistCount: 0
})
const recentActivities = ref([])
const loadingActivities = ref(false)
const systemStatus = ref({
online: true,
database: true,
api: true
})
// 统计卡片数据
const statCards = computed(() => [
{
label: '总歌曲数',
value: formatNumber(stats.value.totalSongs),
icon: Music,
color: 'blue',
trend: stats.value.songsChange !== 0 ? `${Math.abs(stats.value.songsChange)}%` : null,
trendDown: stats.value.songsChange < 0
},
{
label: '注册用户',
value: formatNumber(stats.value.totalUsers),
icon: Users,
color: 'emerald'
},
{
label: '今日排期',
value: formatNumber(stats.value.todaySchedules),
icon: Calendar,
color: 'zinc'
},
{
label: '本周点歌',
value: formatNumber(stats.value.weeklyRequests),
icon: Heart,
color: 'pink',
trend: stats.value.requestsChange !== 0 ? `${Math.abs(stats.value.requestsChange)}%` : null,
trendDown: stats.value.requestsChange < 0
}
])
// 系统状态项
const statusItems = computed(() => [
{
label: '数据库连接',
value: systemStatus.value.database ? '正常' : '异常',
active: systemStatus.value.database
},
{
label: 'API服务',
value: systemStatus.value.api ? '正常' : '异常',
active: systemStatus.value.api
},
{
label: '当前学期',
value: stats.value.currentSemester || '未设置',
active: !!stats.value.currentSemester
},
{
label: '黑名单项目',
value: `${stats.value.blacklistCount} 项`,
active: stats.value.blacklistCount >= 0
},
{ label: '系统版本', value: `v${systemVersion.value}`, active: true }
])
// 快速操作
const quickActions = [
{ label: '管理排期', icon: Calendar, id: 'schedule', primary: true },
{ label: '用户管理', icon: Users, id: 'users' },
{ label: '发送通知', icon: Bell, id: 'notifications' },
{ label: '黑名单管理', icon: Ban, id: 'blacklist' }
]
const formatNumber = (num) => {
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
const getActivityStyle = (type) => {
const styles = {
song: { icon: Music, bg: 'bg-purple-500/10 text-purple-500 border-purple-500/20' },
user: { icon: Users, bg: 'bg-pink-500/10 text-pink-500 border-pink-500/20' },
schedule: { icon: Calendar, bg: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
}
return styles[type] || { icon: Activity, bg: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20' }
}
const loadStats = async () => {
try {
const response = await $fetch('/api/admin/stats', {
...useAuth().getAuthConfig()
})
stats.value = response
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const loadRecentActivities = async () => {
loadingActivities.value = true
try {
const response = await $fetch('/api/admin/activities', {
...useAuth().getAuthConfig()
})
recentActivities.value = response.activities || []
} catch (error) {
console.error('加载活动记录失败:', error)
recentActivities.value = []
} finally {
loadingActivities.value = false
}
}
const refreshActivities = () => {
loadRecentActivities()
}
const formatTime = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return `${Math.floor(diff / 86400000)}天前`
}
const navigateTo = (tab) => {
emit('navigate', tab)
}
onMounted(() => {
loadStats()
loadRecentActivities()
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
@@ -0,0 +1,685 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">播出时段管理</h2>
<p class="text-xs text-zinc-500 mt-1">
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
</p>
</div>
<div class="flex items-center gap-3">
<div
class="bg-zinc-900/40 border border-zinc-800 rounded-xl px-4 py-2 flex items-center gap-3"
>
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>启用播出时段选择</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
]"
@click="toggleGlobalEnabled"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
enablePlayTimeSelection ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
<button
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="openAddForm"
>
<Plus :size="14" /> 添加播出时段
</button>
</div>
</div>
<!-- 信息卡片 -->
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
<Info class="text-blue-500 shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-300">关于时段限制</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
</p>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
>
<div
class="w-10 h-10 border-4 border-blue-600/20 border-t-blue-600 rounded-full animate-spin"
/>
<p class="text-xs font-black text-zinc-500 uppercase tracking-widest">加载中...</p>
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="bg-red-500/5 border border-red-500/10 rounded-xl p-5 flex items-start gap-4"
>
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-300">获取数据失败</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">{{ error }}</p>
</div>
</div>
<!-- 空状态 -->
<div
v-else-if="playTimes.length === 0"
class="flex flex-col items-center justify-center py-20 space-y-6 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
>
<div class="p-6 rounded-xl bg-zinc-950 border border-zinc-800 text-zinc-700">
<Clock :size="48" />
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-bold text-zinc-100">暂无播出时段</h3>
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
</div>
</div>
<!-- 时段网格 -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<TransitionGroup name="list">
<div
v-for="playTime in playTimes"
:key="playTime.id"
:class="[
'group relative bg-zinc-900/30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-black/40',
playTime.enabled ? 'border-zinc-800/80' : 'border-zinc-800/40 opacity-60'
]"
>
<div class="flex items-start justify-between mb-6">
<div
:class="[
'p-3 rounded-xl bg-zinc-950 border border-zinc-800 transition-all',
playTime.enabled ? 'text-blue-500 border-blue-500/20' : 'text-zinc-700'
]"
>
<Clock :size="20" />
</div>
<div class="flex items-center gap-2">
<span
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
playTime.enabled
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
]"
>
{{ playTime.enabled ? '已启用' : '已禁用' }}
</span>
<button class="p-1.5 text-zinc-700 hover:text-zinc-400">
<MoreVertical :size="14" />
</button>
</div>
</div>
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors"
>
{{ playTime.name }}
</h4>
<div
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
>
<span class="text-xl">{{ playTime.startTime || '不限' }}</span>
<div class="w-4 h-[2px] bg-zinc-800" />
<span class="text-xl">{{ playTime.endTime || '不限' }}</span>
</div>
</div>
<p class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2">
{{ playTime.description || '暂无详细描述...' }}
</p>
</div>
<div class="mt-8 pt-6 border-t border-zinc-800/50 flex items-center justify-between">
<div class="flex gap-2">
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
@click="editPlayTime(playTime)"
>
<Edit2 :size="14" />
</button>
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
@click="confirmDelete(playTime)"
>
<Trash2 :size="14" />
</button>
</div>
<button
:class="[
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
playTime.enabled
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="togglePlayTimeStatus(playTime)"
>
<Power :size="12" />
{{ playTime.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
</TransitionGroup>
</div>
<!-- 添加/编辑弹窗 -->
<Transition name="modal">
<div
v-if="showAddForm || editingPlayTime"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
<div
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
</h3>
<button
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
@click="cancelForm"
>
<X :size="20" />
</button>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段名称</label
>
<input
v-model="formData.name"
type="text"
placeholder="例如: 午间广播"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>开始时间 (可选)</label
>
<div class="relative">
<input
v-model="formData.startTime"
type="time"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
:size="14"
/>
</div>
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制开始时间</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>结束时间 (可选)</label
>
<div class="relative">
<input
v-model="formData.endTime"
type="time"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
:size="14"
/>
</div>
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制结束时间</p>
</div>
</div>
<div class="space-y-2">
<label
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 flex items-center gap-2"
>
<AlignLeft :size="10" /> 描述 (可选)
</label>
<textarea
v-model="formData.description"
placeholder="请输入时段描述信息..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
/>
</div>
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="formData.enabled"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>启用此播出时段</span
>
</label>
<div
v-if="formError"
class="bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-[11px] text-red-400"
>
{{ formError }}
</div>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
@click="cancelForm"
>
取消
</button>
<button
:disabled="formSubmitting"
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
@click="savePlayTime"
>
{{ formSubmitting ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 删除确认弹窗 -->
<Transition name="modal">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
@click="showDeleteConfirm = false"
/>
<div
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex flex-col items-center py-4 space-y-6">
<div
class="w-16 h-16 rounded-xl bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/10 shadow-xl shadow-red-900/5"
>
<AlertCircle :size="32" />
</div>
<div class="text-center space-y-2 px-4">
<h4 class="text-lg font-bold text-zinc-100">
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
此操作不可恢复,相关的歌曲点播和排期的时段设置将受影响或被清除。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 text-zinc-500 text-xs font-black rounded-lg transition-all hover:bg-zinc-800"
@click="showDeleteConfirm = false"
>
取消
</button>
<button
:disabled="deleteInProgress"
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95"
@click="deletePlayTime"
>
{{ deleteInProgress ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
import type { PlayTime } from '~/types'
import {
Plus,
Clock,
Edit2,
Trash2,
MoreVertical,
Power,
Info,
AlertCircle,
X,
AlignLeft
} from 'lucide-vue-next'
const { getAuthConfig, isAdmin } = useAuth()
const { showToast: showNotification } = useToast()
const playTimes = ref<PlayTime[]>([])
const loading = ref(false)
const error = ref('')
const showAddForm = ref(false)
const editingPlayTime = ref<PlayTime | null>(null)
const playTimeToDelete = ref<PlayTime | null>(null)
const showDeleteConfirm = ref(false)
const formSubmitting = ref(false)
const deleteInProgress = ref(false)
const formError = ref('')
const enablePlayTimeSelection = ref(false)
// 表单数据
const formData = reactive({
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
// 初始化
onMounted(async () => {
await fetchPlayTimes()
await fetchSystemSettings()
})
// 获取播出时段列表
const fetchPlayTimes = async () => {
if (!isAdmin.value) {
error.value = '只有管理员才能管理播出时段'
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
...authConfig
})
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
// 首先按启用状态排序
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1 // 启用的排在前面
}
// 然后按是否有时间排序
const aHasTime = !!(a.startTime || a.endTime)
const bHasTime = !!(b.startTime || b.endTime)
if (aHasTime !== bHasTime) {
return aHasTime ? -1 : 1 // 有时间的排在前面
}
// 如果都有时间,按开始时间排序
if (aHasTime && bHasTime) {
if (a.startTime && b.startTime) {
return a.startTime.localeCompare(b.startTime)
} else if (a.startTime) {
return -1
} else if (b.startTime) {
return 1
}
}
// 最后按名称排序
return a.name.localeCompare(b.name)
})
} catch (err: any) {
error.value = err.message || '获取播出时段失败'
} finally {
loading.value = false
}
}
// 获取系统设置
const fetchSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/system-settings', {
...authConfig
})
enablePlayTimeSelection.value = data.enablePlayTimeSelection
} catch (err: any) {
console.error('获取系统设置失败:', err.message)
}
}
// 切换全局启用状态
const toggleGlobalEnabled = async () => {
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
await updateSystemSettings()
}
// 更新系统设置
const updateSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
await $fetch('/api/admin/system-settings', {
method: 'POST',
body: {
enablePlayTimeSelection: enablePlayTimeSelection.value
},
...authConfig
})
showNotification('系统设置已更新', 'success')
} catch (err: any) {
error.value = err.message || '更新系统设置失败'
showNotification(error.value, 'error')
// 如果失败,恢复状态
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
}
}
// 打开添加表单
const openAddForm = () => {
showAddForm.value = true
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
}
// 编辑播出时段
const editPlayTime = (playTime: PlayTime) => {
editingPlayTime.value = playTime
Object.assign(formData, {
id: playTime.id,
name: playTime.name,
startTime: playTime.startTime || '',
endTime: playTime.endTime || '',
description: playTime.description || '',
enabled: playTime.enabled
})
}
// 切换播出时段状态
const togglePlayTimeStatus = async (playTime: PlayTime) => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
await $fetch(`/api/admin/play-times/${playTime.id}`, {
method: 'PATCH',
body: {
enabled: !playTime.enabled
},
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
showNotification(playTime.enabled ? '播出时段已禁用' : '播出时段已启用', 'success')
} catch (err: any) {
error.value = err.message || '更新播出时段状态失败'
showNotification(error.value, 'error')
}
}
// 确认删除
const confirmDelete = (playTime: PlayTime) => {
playTimeToDelete.value = playTime
showDeleteConfirm.value = true
}
// 删除播出时段
const deletePlayTime = async () => {
if (!playTimeToDelete.value || !isAdmin.value) return
deleteInProgress.value = true
try {
const authConfig = getAuthConfig()
await $fetch(`/api/admin/play-times/${playTimeToDelete.value.id}`, {
method: 'DELETE',
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
showDeleteConfirm.value = false
playTimeToDelete.value = null
showNotification('播出时段已删除', 'success')
} catch (err: any) {
error.value = err.message || '删除播出时段失败'
showNotification(error.value, 'error')
} finally {
deleteInProgress.value = false
}
}
// 保存播出时段
const savePlayTime = async () => {
formError.value = ''
// 时间验证(仅当两个时间都有填写时才进行比较)
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
formError.value = '开始时间必须早于结束时间'
return
}
// 至少要有名称
if (!formData.name.trim()) {
formError.value = '时段名称不能为空'
return
}
// 检查名称是否重复
const isUpdate = !!editingPlayTime.value
const nameExists = playTimes.value.some(
(pt) =>
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
(!isUpdate || pt.id !== formData.id)
)
if (nameExists) {
formError.value = '播出时段名称已存在,请使用其他名称'
return
}
formSubmitting.value = true
try {
const authConfig = getAuthConfig()
await $fetch(isUpdate ? `/api/admin/play-times/${formData.id}` : '/api/admin/play-times', {
method: isUpdate ? 'PUT' : 'POST',
body: {
name: formData.name.trim(),
startTime: formData.startTime || null,
endTime: formData.endTime || null,
description: formData.description || null,
enabled: formData.enabled
},
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
cancelForm()
showNotification(isUpdate ? '播出时段已更新' : '播出时段已创建', 'success')
} catch (err: any) {
formError.value = err.message || '保存播出时段失败'
showNotification(formError.value, 'error')
} finally {
formSubmitting.value = false
}
}
// 取消表单
const cancelForm = () => {
showAddForm.value = false
editingPlayTime.value = null
formError.value = ''
// 重置表单数据
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
}
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: all 0.3s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.95);
opacity: 0;
}
</style>
@@ -0,0 +1,857 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
<!-- 顶部标题和全局开关 -->
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
<div class="space-y-1">
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">投稿开放管理</h2>
<p class="text-xs text-zinc-500">管理全校师生点歌投稿的开放时间、限制数量及全局状态控制</p>
</div>
<div
class="flex flex-wrap items-center gap-4 bg-zinc-900/40 border border-zinc-800 p-2 rounded-3xl"
>
<div class="flex items-center gap-3 px-3 border-r border-zinc-800/50 mr-1">
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>当前状态</span
>
<span
:class="[
'px-2 py-0.5 rounded text-[10px] font-black uppercase transition-all',
hitRequestTime ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'
]"
>
{{ hitRequestTime ? '已开放' : '已关闭' }}
</span>
</div>
<div class="flex items-center gap-3 px-2">
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
>启用投稿</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enableRequest ? 'bg-blue-600' : 'bg-zinc-800'
]"
@click="toggleGlobalRequest"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
enableRequest ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
<div class="flex items-center gap-3 px-2 border-l border-zinc-800/50">
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
>启用时段限制</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enableRequestTimeLimitation ? 'bg-purple-600' : 'bg-zinc-800'
]"
@click="toggleTimeLimitation"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
enableRequestTimeLimitation ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- 添加时段按钮卡片 -->
<button
class="group relative h-full min-h-[220px] bg-zinc-900/20 border-2 border-dashed border-zinc-800 rounded-3xl flex flex-col items-center justify-center gap-4 hover:bg-blue-600/5 hover:border-blue-500/40 transition-all active:scale-95"
@click="showAddForm = true"
>
<div
class="w-14 h-14 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 group-hover:text-blue-500 group-hover:bg-blue-600/10 group-hover:border-blue-500/20 transition-all"
>
<Plus :size="28" />
</div>
<div class="text-center">
<h4
class="text-sm font-black text-zinc-400 group-hover:text-blue-400 transition-colors uppercase tracking-widest"
>
添加投稿时段
</h4>
<p class="text-[10px] text-zinc-600 mt-1">创建一个新的投稿开放周期</p>
</div>
</button>
<!-- 时段列表 -->
<TransitionGroup
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-for="slot in requestTimes"
:key="slot.id"
:class="[
'group bg-zinc-900/40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-black/40',
slot.enabled && !slot.past ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60'
]"
>
<div class="flex items-start justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 text-zinc-400 transition-colors',
slot.enabled && !slot.past
? 'text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/10'
: ''
]"
>
<Calendar :size="22" />
</div>
<div class="flex items-center gap-2">
<span
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
slot.past
? 'bg-red-500/10 text-red-500 border-red-500/20'
: slot.enabled
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
]"
>
{{ slot.past ? '已过期' : slot.enabled ? '已启用' : '已禁用' }}
</span>
</div>
</div>
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-zinc-100 flex items-baseline gap-2 group-hover:text-blue-400 transition-colors"
>
{{ slot.name }}
<span class="text-xs font-bold text-zinc-600 tracking-tight"
>({{ slot.accepted }}/{{ slot.expected || '∞' }})</span
>
</h4>
<div class="mt-3 space-y-2">
<div class="flex items-center gap-2 text-zinc-500 font-bold">
<Clock :size="12" class="text-zinc-700" />
<span class="text-[10px] uppercase tracking-tighter truncate">{{
slot.startTime || '不限'
}}</span>
</div>
<div class="flex items-center gap-2 text-zinc-500 font-bold">
<div class="w-3 h-[2px] bg-zinc-800 ml-1.5" />
<span class="text-[10px] uppercase tracking-tighter truncate">{{
slot.endTime || '不限'
}}</span>
</div>
</div>
</div>
<p
class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2 italic"
>
{{ slot.description || '暂无详细描述...' }}
</p>
<!-- 投稿进度条 -->
<div class="space-y-1.5">
<div
class="flex justify-between text-[9px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>
<span>投稿进度</span>
<span
>{{
slot.expected > 0
? Math.min(100, Math.round((slot.accepted / slot.expected) * 100))
: 0
}}%</span
>
</div>
<div
class="h-1.5 w-full bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50"
>
<div
class="h-full transition-all duration-500"
:style="{
width: `${slot.expected > 0 ? Math.min(100, (slot.accepted / slot.expected) * 100) : 0}%`
}"
:class="
slot.expected > 0 && slot.accepted >= slot.expected
? 'bg-rose-500'
: 'bg-blue-500'
"
/>
</div>
</div>
</div>
<div class="pt-6 border-t border-zinc-800/50 flex items-center justify-between">
<div class="flex gap-2">
<button
v-show="!slot.past"
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
@click="editRequestTime(slot)"
>
<Edit2 :size="14" />
</button>
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
@click="confirmDelete(slot)"
>
<Trash2 :size="14" />
</button>
</div>
<button
v-show="!slot.past"
:class="[
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
slot.enabled
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="toggleRequestTimeStatus(slot)"
>
<Power :size="12" />
{{ slot.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
</TransitionGroup>
</div>
<!-- 统计概览 -->
<div
class="bg-zinc-900/20 border border-zinc-800 rounded-3xl p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
>
<div v-for="(stat, i) in stats" :key="i" class="flex items-center gap-4 group">
<div
:class="[
'w-12 h-12 rounded-2xl bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-500 transition-colors',
`group-hover:text-${stat.color}-500`
]"
>
<component :is="stat.icon" :size="20" />
</div>
<div>
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
{{ stat.label }}
</p>
<h5 class="text-xl font-black text-zinc-200">{{ stat.value }}</h5>
</div>
</div>
</div>
<!-- 添加/编辑模态框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showAddForm || editingRequestTime"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
@click.self="cancelForm"
>
<div
class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
>
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="cancelForm">
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-6 max-h-[70vh] overflow-y-auto">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段名称</label
>
<input
v-model="formData.name"
type="text"
placeholder="例如: 2025冬季常规点歌"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</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-zinc-600 uppercase tracking-widest px-1"
>开始日期时间</label
>
<input
v-model="formData.startTime"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>结束日期时间</label
>
<input
v-model="formData.endTime"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
</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-zinc-600 uppercase tracking-widest px-1"
>计划投稿数</label
>
<div class="relative group">
<input
v-model="formData.expected"
type="number"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
<div
class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-zinc-600 font-bold text-[10px]"
>
<Hash :size="12" />
首歌曲
</div>
</div>
<p class="text-[9px] text-zinc-600 px-1">填 0 表示不限制投稿总数</p>
</div>
<div class="space-y-2 flex flex-col justify-center pt-2">
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="formData.enabled"
type="checkbox"
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>启用此投稿时段</span
>
<p class="text-[9px] text-zinc-600 font-medium">
只有启用的时段才会根据时间限制生效
</p>
</div>
</label>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段描述 (可选)</label
>
<textarea
v-model="formData.description"
placeholder="请输入针对此开放周期的特殊规则或介绍信息..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
/>
</div>
<div
v-if="formError"
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-500 text-xs font-bold"
>
<AlertCircle :size="16" />
{{ formError }}
</div>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
@click="cancelForm"
>
取消
</button>
<button
:disabled="formSubmitting"
class="px-10 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 disabled:opacity-50"
@click="saveRequestTime"
>
{{ formSubmitting ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 删除确认模态框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
@click.self="showDeleteConfirm = false"
>
<div
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden p-8"
>
<div class="flex flex-col items-center space-y-6">
<div
class="w-16 h-16 rounded-[2rem] bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-xl shadow-red-900/10"
>
<Trash2 :size="28" />
</div>
<div class="text-center space-y-2 px-4">
<h4 class="text-lg font-bold text-zinc-100">
确定要彻底移除 "{{ RequestTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
删除时段后,原本属于此周期的投稿关联记录可能失效。建议先尝试
<span class="text-zinc-300 font-bold">禁用</span> 该时段。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-2xl transition-all"
@click="showDeleteConfirm = false"
>
保留时段
</button>
<button
:disabled="deleteInProgress"
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-2xl shadow-xl shadow-red-900/20 transition-all active:scale-95 disabled:opacity-50"
@click="deleteRequestTime"
>
{{ deleteInProgress ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- 说明部分 -->
<div class="flex flex-col md:flex-row gap-6">
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
<h4
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<CheckCircle2 :size="14" class="text-emerald-500" /> 逻辑优先级说明
</h4>
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
<li class="flex gap-2">
<span class="text-zinc-700 font-black">1.</span>
<span
><span class="text-zinc-300">全局开关</span>
具有最高优先级,关闭后所有投稿均不可用。</span
>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">2.</span>
<span
>开启 <span class="text-zinc-300">时段限制</span> 后,系统会检查当前时间是否处于任一
<span class="text-zinc-300">已启用</span> 且未达到
<span class="text-zinc-300">计划数</span> 的时段内。</span
>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">3.</span>
<span>若关闭时段限制,则全天候均可投稿(只要全局开关开启)。</span>
</li>
</ul>
</div>
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
<h4
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<XCircle :size="14" class="text-rose-500" /> 注意事项
</h4>
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
<li class="flex gap-2">
<span class="text-zinc-700 font-black">•</span>
<span>时段重叠时,系统将合并开放范围。</span>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">•</span>
<span>计划投稿数设置为 0 时,系统将忽略数量上限检查。</span>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">•</span>
<span>过期的时段将自动显示为“已过期”,不可编辑或重新启用。</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
import { useAuth } from '~/composables/useAuth'
import type { RequestTime } from '~/types'
import {
Plus,
Calendar,
Clock,
Edit2,
Trash2,
Power,
AlertCircle,
Hash,
CheckCircle2,
XCircle,
X,
BarChart3,
Filter
} from 'lucide-vue-next'
const { getAuthConfig, isAdmin } = useAuth()
const requestTimes = ref<RequestTime[]>([])
const loading = ref(false)
const error = ref('')
const showAddForm = ref(false)
const editingRequestTime = ref<RequestTime | null>(null)
const RequestTimeToDelete = ref<RequestTime | null>(null)
const showDeleteConfirm = ref(false)
const formSubmitting = ref(false)
const deleteInProgress = ref(false)
const formError = ref('')
const enableRequestTimeLimitation = ref(false)
const hitRequestTime = ref(false)
const enableRequest = ref(true)
let refreshInterval: any = null
const formData = reactive({
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true,
expected: 0
})
// 统计数据
const stats = computed(() => [
{
label: '活跃时段',
value: requestTimes.value.filter((s) => s.enabled && !s.past).length.toString(),
icon: Clock,
color: 'blue'
},
{
label: '累计已接收',
value: requestTimes.value.reduce((acc, s) => acc + s.accepted, 0).toString(),
icon: BarChart3,
color: 'emerald'
},
{
label: '计划总容量',
value: requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0) || '不限',
icon: Hash,
color: 'purple'
},
{
label: '剩余总名额',
value: (() => {
const totalExpected = requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0)
const totalAccepted = requestTimes.value.reduce((acc, s) => acc + s.accepted, 0)
return totalExpected > 0 ? Math.max(0, totalExpected - totalAccepted).toString() : '不限'
})(),
icon: Filter,
color: 'amber'
}
])
onMounted(async () => {
await fetchRequestTimes()
await fetchSystemSettings()
await fetchRequestTimeHit()
// 每 30 秒自动刷新一次状态,以确保时间同步
refreshInterval = setInterval(fetchRequestTimeHit, 30000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
const fetchRequestTimes = async () => {
if (!isAdmin.value) {
error.value = '只有管理员才能管理投稿开放时段'
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/admin/request-times', {
headers: {
'Content-Type': 'application/json'
},
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `获取投稿开放时段失败: ${response.status}`)
}
const data = await response.json()
requestTimes.value = data.sort((a: RequestTime, b: RequestTime) => {
if (a.past !== b.past) return a.past ? 1 : -1
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
const aHasTime = !!(a.startTime || a.endTime)
const bHasTime = !!(b.startTime || b.endTime)
if (aHasTime !== bHasTime) return aHasTime ? -1 : 1
if (aHasTime && bHasTime) {
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime)
else if (a.startTime) return -1
else if (b.startTime) return 1
}
return a.name.localeCompare(b.name)
})
} catch (err: any) {
error.value = err.message || '获取投稿开放时段失败'
} finally {
loading.value = false
}
}
const fetchSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/admin/system-settings', {
headers: { 'Content-Type': 'application/json' },
...authConfig
})
if (!response.ok) return
const data = await response.json()
enableRequestTimeLimitation.value = data.enableRequestTimeLimitation
enableRequest.value = !data.forceBlockAllRequests
} catch (err: any) {
console.error('获取系统设置失败:', err.message)
}
}
const fetchRequestTimeHit = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/request-times', {
headers: { 'Content-Type': 'application/json' },
...authConfig
})
if (!response.ok) return
const data = await response.json()
hitRequestTime.value = data.hit
} catch (err: any) {
console.error('获取投稿开放状态失败:', err.message)
}
}
const toggleGlobalRequest = async () => {
enableRequest.value = !enableRequest.value
await updateSystemSettings()
await fetchRequestTimeHit()
}
const toggleTimeLimitation = async () => {
enableRequestTimeLimitation.value = !enableRequestTimeLimitation.value
await updateSystemSettings()
await fetchRequestTimeHit()
}
const updateSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enableRequestTimeLimitation: enableRequestTimeLimitation.value,
forceBlockAllRequests: !enableRequest.value
}),
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `更新系统设置失败: ${response.status}`)
}
} catch (err: any) {
error.value = err.message || '更新系统设置失败'
}
}
const editRequestTime = (RequestTime: RequestTime) => {
editingRequestTime.value = RequestTime
Object.assign(formData, {
id: RequestTime.id,
name: RequestTime.name,
startTime: RequestTime.startTime,
endTime: RequestTime.endTime,
description: RequestTime.description || '',
enabled: RequestTime.enabled,
expected: RequestTime.expected || 0
})
}
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/request-times/${RequestTime.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !RequestTime.enabled }),
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `更新投稿开放时段状态失败: ${response.status}`)
}
await fetchRequestTimes()
await fetchRequestTimeHit()
} catch (err: any) {
error.value = err.message || '更新投稿开放时段状态失败'
}
}
const confirmDelete = (RequestTime: RequestTime) => {
RequestTimeToDelete.value = RequestTime
showDeleteConfirm.value = true
}
const deleteRequestTime = async () => {
if (!RequestTimeToDelete.value || !isAdmin.value) return
deleteInProgress.value = true
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/request-times/${RequestTimeToDelete.value.id}`, {
method: 'DELETE',
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `删除投稿开放时段失败: ${response.status}`)
}
await fetchRequestTimes()
await fetchRequestTimeHit()
showDeleteConfirm.value = false
RequestTimeToDelete.value = null
} catch (err: any) {
error.value = err.message || '删除投稿开放时段失败'
} finally {
deleteInProgress.value = false
}
}
const saveRequestTime = async () => {
formError.value = ''
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
formError.value = '开始时间必须早于结束时间'
return
}
if (!formData.name.trim()) {
formError.value = '时段名称不能为空'
return
}
const isUpdate = !!editingRequestTime.value
const nameExists = requestTimes.value.some(
(pt) =>
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
(!isUpdate || pt.id !== formData.id)
)
if (nameExists) {
formError.value = '投稿开放时段名称已存在,请使用其他名称'
return
}
formSubmitting.value = true
try {
const authConfig = getAuthConfig()
const response = await fetch(
isUpdate ? `/api/admin/request-times/${formData.id}` : '/api/admin/request-times',
{
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name.trim(),
startTime: formData.startTime || null,
endTime: formData.endTime || null,
description: formData.description || null,
enabled: formData.enabled,
expected: formData.expected || 0
}),
...authConfig
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
errorData.message || `${isUpdate ? '更新' : '创建'}投稿开放时段失败: ${response.status}`
)
}
await fetchRequestTimes()
await fetchRequestTimeHit()
cancelForm()
} catch (err: any) {
formError.value = err.message || '保存投稿开放时段失败'
} finally {
formSubmitting.value = false
}
}
const cancelForm = () => {
showAddForm.value = false
editingRequestTime.value = null
formError.value = ''
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true,
expected: 0
})
}
</script>
<style scoped>
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
@@ -0,0 +1,172 @@
<template>
<div
class="backdrop-blur-md p-6 rounded-xl border border-white/10 bg-slate-800/70 shadow-2xl max-w-[400px] mx-auto text-zinc-100 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_rgba(0,0,0,0.2)]"
>
<h3 class="mb-6 pb-2 border-b border-white/10 text-zinc-100 font-bold text-lg">
为歌曲 "{{ song?.title }}" 创建排期
</h3>
<form @submit.prevent="handleSubmit">
<div class="mb-4">
<label class="block mb-2 font-medium text-zinc-100" for="playDate">播放日期</label>
<input
id="playDate"
v-model="playDate"
class="w-full p-3 border border-white/10 rounded-lg text-base bg-[#0f172a99] text-zinc-100 outline-none transition-colors duration-150 focus:border-indigo-500 focus:shadow-[0_0_0_2px_rgba(99,102,241,0.25)]"
required
type="date"
>
</div>
<!-- 播出时段选择 -->
<div v-if="playTimeEnabled" class="mb-4">
<CustomSelect
v-model="playTimeId"
:options="playTimeOptions"
label="播出时段"
placeholder="未指定"
class-name="w-full"
/>
<div
v-if="song?.preferredPlayTime"
class="mt-3 p-3 bg-white/5 rounded-lg text-sm flex items-start gap-2"
>
<div class="text-base">💡</div>
<div>
用户期望的播出时段:
<span class="font-medium text-indigo-300">
{{ song.preferredPlayTime.name }}
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
({{ formatPlayTimeRange(song.preferredPlayTime) }})
</template>
</span>
</div>
</div>
</div>
<div v-if="error" class="p-3 mt-4 bg-red-500/10 text-red-300 rounded-lg">
{{ error }}
</div>
<div class="flex justify-between gap-4 mt-6">
<button
class="flex-1 p-3 border border-white/10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-white/10 text-zinc-100 hover:bg-white/15"
type="button"
@click="$emit('cancel')"
>
取消
</button>
<button
:disabled="loading"
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-indigo-600 text-white hover:bg-indigo-700 hover:-translate-y-px disabled:bg-indigo-500/50 disabled:cursor-not-allowed disabled:transform-none"
type="submit"
>
{{ loading ? '创建中...' : '创建排期' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useSongs } from '~/composables/useSongs'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const props = defineProps({
song: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['schedule', 'cancel'])
const playDate = ref('')
const playTimeId = ref('')
const error = ref('')
const playTimes = ref([])
const { playTimeEnabled } = useSongs()
// 转换播出时段为 CustomSelect 选项格式
const playTimeOptions = computed(() => {
const options = [{ label: '未指定', value: '' }]
if (playTimes.value && playTimes.value.length > 0) {
playTimes.value.forEach((pt) => {
options.push({
label: `${pt.name} (${pt.startTime} - ${pt.endTime})`,
value: pt.id
})
})
}
return options
})
// 初始化
onMounted(async () => {
await fetchPlayTimes()
// 如果歌曲有期望的播出时段,默认选择该时段
if (props.song?.preferredPlayTimeId) {
playTimeId.value = props.song.preferredPlayTimeId
}
})
// 获取播出时段
const fetchPlayTimes = async () => {
try {
// 使用useSongs中的方法获取播放时段
await useSongs().fetchPlayTimes()
const response = await fetch('/api/admin/play-times')
if (response.ok) {
const data = await response.json()
// 只显示启用的播放时段
playTimes.value = data.filter((pt) => pt.enabled)
}
} catch (err) {
console.error('获取播出时段失败:', err)
}
}
// 格式化播出时段时间范围
const formatPlayTimeRange = (playTime) => {
if (!playTime) return ''
if (playTime.startTime && playTime.endTime) {
return `${playTime.startTime} - ${playTime.endTime}`
} else if (playTime.startTime) {
return `${playTime.startTime} 开始`
} else if (playTime.endTime) {
return `${playTime.endTime} 结束`
}
return '不限时间'
}
const handleSubmit = () => {
error.value = ''
if (!playDate.value) {
error.value = '请选择播放日期'
return
}
const selectedDate = new Date(playDate.value)
// 播出时段ID需要转换为数字或null
const schedulePlayTimeId = playTimeId.value ? parseInt(playTimeId.value) : null
emit('schedule', {
songId: props.song.id,
playDate: selectedDate,
playTimeId: schedulePlayTimeId
})
}
</script>
@@ -0,0 +1,308 @@
<template>
<div class="schedule-item-print">
<div class="item-content">
<!-- 序号 -->
<div v-if="settings.showSequence" class="sequence-number">
{{ schedule.sequence || 1 }}
</div>
<!-- 歌曲封面 -->
<div v-if="settings.showCover" class="cover-section">
<img
v-if="schedule.song.cover"
:alt="schedule.song.title"
:src="convertToHttps(schedule.song.cover)"
class="song-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div class="cover-placeholder" :class="{ 'show': !schedule.song.cover }">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6" />
<path d="m21 12-6-3-6 3-6-3" />
</svg>
</div>
</div>
<!-- 歌曲信息 -->
<div class="song-info">
<div v-if="settings.showTitle" class="song-title">
{{ schedule.song.title }}
<!-- 重播标识 -->
<span v-if="schedule.song.replayRequestCount > 0" class="replay-badge-print"> 重播 </span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ schedule.song.artist }}
</div>
</div>
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
<div v-if="settings.showRequester" class="requester-info">
<span class="label">投稿人:</span>
<span class="value">
{{ schedule.song.requester }}
<span v-if="schedule.song.collaborators && schedule.song.collaborators.length > 0">
& {{ schedule.song.collaborators.map((c) => c.displayName || c.name).join(' & ') }}
</span>
</span>
</div>
<!-- 人数信息 -->
<div v-if="settings.showVotes" class="votes-info">
<span v-if="schedule.song.replayRequestCount > 0" class="label">申请重播:</span>
<span v-else class="label">热度:</span>
<span class="value">{{
schedule.song.replayRequestCount > 0
? schedule.song.replayRequestCount + '人'
: schedule.song.voteCount || 0
}}</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import { convertToHttps } from '~/utils/url'
// 定义props
defineProps({
schedule: {
type: Object,
required: true
},
settings: {
type: Object,
required: true
}
})
// 处理图片加载错误
const handleImageError = (event) => {
event.target.style.display = 'none'
event.target.nextElementSibling?.classList.add('show')
}
</script>
<style scoped>
.schedule-item-print {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #e5e5e5;
page-break-inside: avoid;
}
.item-content {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
}
.sequence-number {
width: 30px;
height: 30px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
color: #333;
flex-shrink: 0;
}
.cover-section {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.song-cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.cover-placeholder {
width: 100%;
height: 100%;
background: #f5f5f5;
border-radius: 4px;
display: none;
align-items: center;
justify-content: center;
color: #999;
}
.cover-placeholder.show {
display: flex;
}
.cover-placeholder svg {
width: 20px;
height: 20px;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-weight: bold;
font-size: 16px;
color: #333;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
}
/* 打印用重播标识 */
.replay-badge-print {
display: inline-block;
padding: 1px 4px;
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 3px;
color: #1976d2;
font-size: 10px;
font-weight: bold;
flex-shrink: 0;
}
.song-artist {
font-size: 14px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-info,
.votes-info {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
white-space: nowrap;
}
.label {
font-weight: 500;
margin-right: 4px;
}
.value {
color: #333;
}
/* 打印样式 */
@media print {
.schedule-item-print {
color: #000 !important;
background: #fff !important;
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
}
.item-content {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
width: 100% !important;
gap: 12px !important;
}
.sequence-number {
background: #f0f0f0 !important;
color: #000 !important;
width: 30px !important;
height: 30px !important;
flex-shrink: 0 !important;
}
.cover-section {
width: 40px !important;
height: 40px !important;
flex-shrink: 0 !important;
}
.song-info {
flex: 1 !important;
min-width: 0 !important;
}
.song-title {
color: #000 !important;
font-size: 16px !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.song-artist,
.label,
.value {
color: #333 !important;
}
.time-range {
color: #666 !important;
}
.requester-info,
.votes-info,
.playtime-info {
display: flex !important;
align-items: center !important;
font-size: 12px !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
}
}
/* 紧凑模式 */
.compact .schedule-item-print {
padding: 4px 0;
}
.compact .item-content {
gap: 8px;
}
.compact .sequence-number {
width: 24px;
height: 24px;
font-size: 12px;
}
.compact .cover-section {
width: 32px;
height: 32px;
}
.compact .song-title {
font-size: 14px;
}
.compact .song-artist {
font-size: 12px;
}
.compact .requester-info,
.compact .votes-info,
.compact .playtime-info {
font-size: 11px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,638 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-10 pb-20 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">学期管理</h2>
<p class="text-xs text-zinc-500 mt-1">
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
</p>
</div>
<button
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="openAddModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
添加新学期
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
<!-- 左侧栏:当前活跃学期 -->
<div class="lg:col-span-5 space-y-6">
<div class="flex items-center gap-2 px-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-amber-500 fill-amber-500"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
当前活跃学期
</h3>
</div>
<div
v-if="currentSemester"
class="relative bg-gradient-to-br from-blue-600 to-blue-700 rounded-3xl p-10 shadow-2xl shadow-blue-900/20 overflow-hidden group"
>
<div class="relative z-10 space-y-6">
<div
class="w-16 h-16 rounded-3xl bg-white/10 backdrop-blur-md flex items-center justify-center text-white border border-white/20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
<div class="space-y-2">
<h4 class="text-3xl font-black text-white tracking-tight leading-tight">
{{ currentSemester.name }}
</h4>
<div class="flex items-center gap-2 text-blue-100/60 font-bold text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
创建于 {{ formatDate(currentSemester.createdAt) }}
</div>
</div>
<div class="pt-4">
<span
class="px-4 py-2 bg-white text-blue-600 text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
>
正在进行中
</span>
</div>
</div>
<!-- 装饰性背景元素 -->
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-white/5 blur-3xl rounded-full" />
<div class="absolute top-10 right-10 opacity-10">
<svg
xmlns="http://www.w3.org/2000/svg"
width="160"
height="160"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-white"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
</div>
<div
v-else
class="bg-zinc-900/30 border border-zinc-800 border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-zinc-700"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-sm font-bold text-zinc-600">未设置当前活跃学期</p>
<button class="text-xs text-blue-500 font-black hover:underline" @click="openAddModal">
立即创建
</button>
</div>
<div class="p-6 bg-zinc-900/20 border border-zinc-800 rounded-3xl flex items-start gap-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-zinc-500 shrink-0 mt-0.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-400">学期切换说明</p>
<p class="text-[11px] text-zinc-600 leading-relaxed">
切换学期后,前端点歌界面将清空旧排期。所有历史数据会自动归档至对应学期下,您仍可在数据中心进行查看。
</p>
</div>
</div>
</div>
<!-- 右侧栏:学期历史记录 -->
<div class="lg:col-span-7 space-y-6">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-zinc-600"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</svg>
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
学期历史记录
</h3>
</div>
<span class="text-[10px] font-black text-zinc-700">共 {{ semesters.length }} 条</span>
</div>
<div class="space-y-4">
<div
v-if="loading && semesters.length === 0"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-zinc-600">加载中...</p>
</div>
<div
v-for="sem in sortedSemesters"
:key="sem.id"
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
:class="
sem.isActive
? 'bg-blue-600/5 border-blue-500/30'
: 'bg-zinc-900/40 border-zinc-800 hover:border-zinc-700'
"
>
<div class="flex items-center gap-5">
<div
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
:class="
sem.isActive
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="21 8 21 21 3 21 3 8" />
<rect x="1" y="3" width="22" height="5" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
</div>
<div>
<h5
class="font-bold transition-colors"
:class="
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
"
>
{{ sem.name }}
</h5>
<p class="text-[10px] text-zinc-600 font-medium uppercase tracking-widest mt-0.5">
创建于 {{ formatDate(sem.createdAt) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div
v-if="sem.isActive"
class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-500 rounded-xl border border-emerald-500/20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span class="text-[10px] font-black uppercase tracking-widest">当前学期</span>
</div>
<template v-else>
<button
:disabled="loading"
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white text-[10px] font-black rounded-xl border border-zinc-700 transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="setActive(sem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
设为当前
</button>
<button
:disabled="loading"
class="p-2.5 text-zinc-700 hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="deleteSemester(sem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</template>
</div>
</div>
<div
v-if="semesters.length === 0 && !loading"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
</div>
</div>
</div>
</div>
<!-- 添加学期弹窗 -->
<div
v-if="showAddModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
@click.stop
>
<div class="p-8 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
<button
class="p-2 text-zinc-500 hover:text-white transition-colors"
@click="closeAddModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>学期名称</label
>
<input
v-model="newSemester.name"
type="text"
placeholder="例如: 2025学年第一学期"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 font-bold transition-all"
>
</div>
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="newSemester.isActive"
type="checkbox"
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600 transition-all"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>设为当前活跃学期</span
>
<p class="text-[10px] text-zinc-600 font-medium">
创建后将立即激活此学期,原学期将变为历史状态
</p>
</div>
</label>
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-amber-500 shrink-0 mt-0.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-[10px] text-zinc-500 leading-normal">
提示:请按照统一的命名规范设置学期名称,这有助于数据归档后的检索与分析。
</p>
</div>
</div>
</div>
<div class="p-6 bg-zinc-800/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors"
@click="closeAddModal"
>
取消
</button>
<button
:disabled="submitting || !newSemester.name.trim()"
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:opacity-50 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
@click="handleAddSemester"
>
{{ submitting ? '创建中...' : '创建学期' }}
</button>
</div>
</div>
</div>
<!-- 确认删除对话框 -->
<ConfirmDialog
:loading="loading"
:show="showDeleteDialog"
cancel-text="取消"
confirm-text="确认删除"
:message="deleteMessage"
title="确认删除学期"
type="danger"
@close="showDeleteDialog = false"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useToast } from '~/composables/useToast'
const { showToast: showNotification } = useToast()
const {
semesters,
currentSemester,
loading,
error,
fetchSemesters,
fetchCurrentSemester,
createSemester,
setActiveSemester,
deleteSemester: deleteSemesterAPI
} = useSemesters()
const showAddModal = ref(false)
const showDeleteDialog = ref(false)
const deleteTargetId = ref(null)
const deleteTargetName = ref('')
const submitting = ref(false)
const newSemester = ref({
name: '',
isActive: false
})
// 按创建时间排序
const sortedSemesters = computed(() => {
return [...semesters.value].sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt)
})
})
// 删除确认信息
const deleteMessage = computed(() => {
return `确定要彻底删除 "${deleteTargetName.value}" 吗?此操作将同步清除该学期下的所有点歌记录、排期数据和统计信息。数据一旦删除将无法找回。`
})
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
// 设置活跃学期
const setActive = async (semesterId) => {
try {
const success = await setActiveSemester(semesterId)
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
// 或者直接依据success返回值判断
if (success) {
showNotification('活跃学期设置成功!', 'success')
} else {
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
showNotification(error.value || '设置活跃学期失败,请稍后重试', 'error')
}
} catch (err) {
showNotification(err.message || '设置失败', 'error')
}
}
// 准备删除学期
const deleteSemester = (semesterId) => {
const semester = semesters.value.find((s) => s.id === semesterId)
if (semester) {
deleteTargetId.value = semesterId
deleteTargetName.value = semester.name
showDeleteDialog.value = true
}
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetId.value) return
try {
const success = await deleteSemesterAPI(deleteTargetId.value)
if (success) {
showNotification('学期删除成功!', 'success')
} else {
showNotification(error.value || '删除学期失败', 'error')
}
} catch (err) {
showNotification(err.message || '删除失败', 'error')
}
showDeleteDialog.value = false
deleteTargetId.value = null
deleteTargetName.value = ''
}
// 处理添加学期
const handleAddSemester = async () => {
if (!newSemester.value.name.trim()) return
submitting.value = true
try {
const result = await createSemester({
name: newSemester.value.name,
isActive: newSemester.value.isActive
})
if (result) {
showNotification('学期创建成功!', 'success')
closeAddModal()
} else {
showNotification(error.value || '创建学期失败', 'error')
}
} catch (err) {
console.error('创建学期失败:', err)
showNotification(err.message || '创建学期失败', 'error')
} finally {
submitting.value = false
}
}
// 关闭添加弹窗
const closeAddModal = () => {
showAddModal.value = false
newSemester.value = {
name: '',
isActive: false
}
}
const getRecommendedName = () => {
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
let academicYear = year
let term = '第一学期'
if (month >= 3 && month <= 8) {
academicYear = year - 1
term = '第二学期'
} else if (month <= 2) {
academicYear = year - 1
term = '第一学期'
} else {
academicYear = year
term = '第一学期'
}
return `${academicYear}学年${term}`
}
const openAddModal = () => {
newSemester.value.name = getRecommendedName()
showAddModal.value = true
}
// 组件挂载时获取数据
onMounted(async () => {
await Promise.all([fetchCurrentSemester(), fetchSemesters()])
})
</script>
<style scoped></style>
@@ -0,0 +1,256 @@
<!-- 侧边栏组件 -->
<template>
<aside
:class="[
'fixed inset-y-0 left-0 z-50 w-64 bg-[#09090b] border-r border-zinc-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0',
isOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex flex-col h-full p-4">
<!-- 品牌标识区域 -->
<div class="flex items-center px-2 mb-6 mt-2">
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<!-- Logo 图标 -->
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
<img :src="logo" alt="VoiceHub Logo" class="w-8 h-8 object-contain" >
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
<p
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
>
管理控制台
</p>
</div>
</NuxtLink>
</div>
<!-- 导航菜单区域 -->
<nav class="flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
<div v-for="(group, idx) in menuGroups" :key="idx" class="space-y-1">
<template v-if="shouldShowGroup(group)">
<!-- 分组标题 -->
<h3 class="px-3 text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] mb-2">
{{ group.section }}
</h3>
<!-- 菜单项列表 -->
<template v-for="item in group.items" :key="item.id">
<button
v-if="permissions.canAccessPage(item.permissionId || item.id)"
:class="[
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/80 focus-visible:ring-inset',
activeTab === item.id
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 border-transparent'
]"
@click="onNavigate(item.id)"
>
<!-- 菜单图标 -->
<component
:is="item.icon"
:size="18"
:class="
activeTab === item.id
? 'text-blue-400'
: 'text-zinc-500 group-hover:text-zinc-300'
"
/>
<span class="truncate">{{ item.label }}</span>
<!-- 选中状态指示器 -->
<div
v-if="activeTab === item.id"
class="ml-auto w-1 h-1 bg-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
/>
</button>
</template>
</template>
</div>
</nav>
<!-- 用户信息及退出登录 -->
<div class="mt-4 pt-4 border-t border-zinc-800">
<div
class="flex items-center gap-3 p-3 rounded-lg bg-zinc-900/50 border border-zinc-800/50 hover:bg-zinc-800/30 transition-colors"
>
<!-- 用户头像/首字母 -->
<img
v-if="currentUser?.avatar && !avatarError"
:src="currentUser.avatar"
class="w-10 h-10 rounded-lg object-cover border border-zinc-700 shrink-0"
@error="avatarError = true"
>
<div
v-else
class="w-10 h-10 rounded-lg bg-zinc-800 flex items-center justify-center text-zinc-400 font-bold border border-zinc-700 shrink-0"
>
{{ (currentUser?.name || '管').charAt(0) }}
</div>
<!-- 用户详细信息 -->
<div class="flex-1 min-w-0">
<p class="text-xs font-black truncate text-zinc-100">
{{ currentUser?.name || '管理员' }}
</p>
<p
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
>
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
</p>
</div>
<!-- 退出按钮 -->
<button
class="p-2 text-zinc-600 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
title="退出登录"
@click="$emit('logout')"
>
<LogOut :size="16" />
</button>
</div>
</div>
</div>
</aside>
</template>
<script setup>
/**
* 后台管理侧边栏组件
*/
import {
LayoutDashboard,
CalendarDays,
Printer,
Music2,
BarChart3,
Users,
Key,
Bell,
Mail,
LogOut,
Clock,
FileEdit,
BookOpen,
Ban,
Globe,
Database,
Lock
} from 'lucide-vue-next'
import logo from '~~/public/images/logo.png'
const avatarError = ref(false)
const props = defineProps({
// 侧边栏是否打开(移动端)
isOpen: Boolean,
// 当前激活的标签页 ID
activeTab: String,
// 当前登录用户信息
currentUser: Object,
// 权限控制对象
permissions: Object,
// 站点标题
siteTitle: String
})
watch(
() => props.currentUser?.avatar,
() => {
avatarError.value = false
}
)
const emit = defineEmits(['navigate', 'close', 'logout'])
// 菜单分组配置
const menuGroups = [
{
section: '概览',
items: [{ icon: LayoutDashboard, label: '数据概览', id: 'overview' }]
},
{
section: '内容管理',
items: [
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
{ icon: Printer, label: '打印排期', id: 'print' },
{ icon: Music2, label: '歌曲管理', id: 'songs' },
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
]
},
{
section: '用户管理',
items: [{ icon: Users, label: '用户管理', id: 'users' }]
},
{
section: 'API管理',
items: [{ icon: Key, label: 'API密钥管理', id: 'api-keys' }]
},
{
section: '系统管理',
items: [
{ icon: Bell, label: '通知管理', id: 'notifications' },
{ icon: Mail, label: '邮件配置', id: 'smtp-config' },
{ icon: Clock, label: '播出时段', id: 'playtimes' },
{ icon: FileEdit, label: '投稿管理', id: 'request-times' },
{ icon: BookOpen, label: '学期管理', id: 'semesters' },
{ icon: Ban, label: '黑名单管理', id: 'blacklist' },
{ icon: Globe, label: '站点配置', id: 'site-config' },
{ icon: Database, label: '数据库操作', id: 'database' }
]
},
{
section: '账户管理',
items: [{ icon: Lock, label: '修改密码', id: 'password' }]
}
]
/**
* 判断是否应该显示该菜单组
* @param {Object} group 菜单组对象
*/
const shouldShowGroup = (group) => {
if (!props.permissions) return true
return group.items.some((item) => props.permissions.canAccessPage(item.permissionId || item.id))
}
/**
* 导航点击处理
* @param {string} id 菜单项 ID
*/
const onNavigate = (id) => {
if (id === 'password') {
navigateTo('/change-password')
return
}
emit('navigate', id)
}
/**
* 获取角色显示名称
* @param {string} role 角色标识
*/
const getRoleDisplayName = (role) => {
const roleNames = {
USER: '普通用户',
SONG_ADMIN: '歌曲管理员',
ADMIN: '管理员',
SUPER_ADMIN: '超级管理员'
}
return roleNames[role] || role
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
@@ -0,0 +1,536 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">站点配置</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
</p>
</div>
<div class="flex gap-3">
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetForm"
>
<RotateCcw :size="14" /> 重置
</button>
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
@click="saveConfig"
>
<template v-if="saving">
<div
class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
保存中...
</template>
<template v-else-if="saveSuccess"> <CheckCircle2 :size="14" /> 已保存 </template>
<template v-else> <Save :size="14" /> 保存配置 </template>
</button>
</div>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
<div
class="w-8 h-8 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<p class="text-zinc-500 text-sm">加载配置中...</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 基础信息 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Globe :size="16" class="text-blue-500" /> 基础信息
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">站点标题</label>
<input
v-model="formData.siteTitle"
type="text"
placeholder="请输入站点标题"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">备案号 (ICP)</label>
<input
v-model="formData.icpNumber"
type="text"
placeholder="请输入备案号"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">公安联网备案号</label>
<input
v-model="formData.gonganNumber"
type="text"
placeholder="请输入公安备案号 (如:陕公网安备61011302001964号)"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">站点描述</label>
<textarea
v-model="formData.siteDescription"
:rows="3"
placeholder="请输入站点描述"
:class="[inputClass, 'resize-none']"
/>
</div>
</div>
</section>
<!-- 视觉识别 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">站点 Logo URL</label>
<input
v-model="formData.siteLogoUrl"
type="text"
placeholder="请输入Logo图片URL"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">首页学校 Logo URL (大尺寸)</label>
<input
v-model="formData.schoolLogoHomeUrl"
type="text"
placeholder="请输入首页学校Logo URL"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">打印排期 Logo URL (小尺寸)</label>
<input
v-model="formData.schoolLogoPrintUrl"
type="text"
placeholder="请输入打印页学校Logo URL"
:class="inputClass"
>
</div>
</div>
</section>
<!-- 投稿须知 -->
<section
class="lg:col-span-2 bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 space-y-6"
>
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<FileText :size="16" class="text-emerald-500" /> 投稿须知
</h3>
<textarea
v-model="formData.submissionGuidelines"
:rows="6"
placeholder="请输入投稿须知内容"
:class="[inputClass, 'font-mono text-xs leading-relaxed min-h-[150px]']"
/>
</section>
<!-- 投稿逻辑设置 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Settings2 :size="16" class="text-amber-500" /> 投稿逻辑设置
</h3>
<div class="space-y-6">
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用联合投稿</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户添加联合投稿人并发起协作投稿</p>
</div>
<input
v-model="formData.enableCollaborativeSubmission"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿备注留言</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户在投稿时附加公开或仅管理员可见的备注</p>
</div>
<input
v-model="formData.enableSubmissionRemarks"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用重播申请</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户对本学期已播放过的歌曲再次申请</p>
</div>
<input
v-model="formData.enableReplayRequests"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div class="space-y-4">
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿限额</p>
<p class="text-[10px] text-zinc-500 mt-0.5">限制单个用户的点歌频率</p>
</div>
<input
v-model="formData.enableSubmissionLimit"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div v-if="formData.enableSubmissionLimit" class="space-y-4">
<div class="grid grid-cols-3 gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-xl">
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'daily'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('daily')"
>
每日限额
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'weekly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('weekly')"
>
每周限额
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'monthly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('monthly')"
>
每月限额
</button>
</div>
<div>
<label :class="labelClass"
>{{ currentLimitType === 'daily' ? '单日' : (currentLimitType === 'weekly' ? '单周' : '单月') }}投稿上限</label
>
<div class="relative">
<input
v-model.number="currentLimitValue"
type="number"
min="0"
:class="inputClass"
>
<span
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-zinc-700 uppercase"
>首 / 人</span
>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 安全与隐私设置 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Shield :size="16" class="text-rose-500" /> 安全与隐私设置
</h3>
<div class="space-y-4">
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 pt-0.5">
<input
id="show-keywords"
v-model="formData.showBlacklistKeywords"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<label for="show-keywords" class="cursor-pointer">
<p class="text-xs font-bold text-zinc-200">显示黑名单具体关键词</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
</p>
</label>
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 pt-0.5">
<input
id="hide-students"
v-model="formData.hideStudentInfo"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<label for="hide-students" class="cursor-pointer">
<p class="text-xs font-bold text-zinc-200">隐藏学生详细信息</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
</p>
</label>
</div>
</div>
<div
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-blue-500 shrink-0 mt-0.5" :size="14" />
<p class="text-[10px] text-zinc-500 leading-normal">
站点基础配置在保存后将立即对所有终端生效。请在修改关键业务逻辑(如投稿限额)前确保已知晓对现有用户的影响。
</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
Globe,
ImageIcon,
FileText,
Settings2,
Shield,
Save,
RotateCcw,
CheckCircle2,
AlertCircle
} from 'lucide-vue-next'
import { useToast } from '~/composables/useToast'
const { showToast: showNotification } = useToast()
const loading = ref(true)
const saving = ref(false)
const saveSuccess = ref(false)
// 样式类常量
const inputClass =
'w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800'
const labelClass = 'text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2'
const cardClass = 'bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 shadow-xl space-y-6'
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
3. 禁止投递含有违规内容的歌曲
4. 点播的歌曲将由管理员进行审核
5. 审核通过后将安排在播放时段播出
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
8. 最终解释权归广播站所有`
const formData = ref({
siteTitle: '',
siteLogoUrl: '',
schoolLogoHomeUrl: '',
schoolLogoPrintUrl: '',
siteDescription: '',
submissionGuidelines: '',
icpNumber: '',
gonganNumber: '',
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
enableReplayRequests: false,
enableSubmissionLimit: false,
dailySubmissionLimit: 5,
weeklySubmissionLimit: null,
monthlySubmissionLimit: null,
showBlacklistKeywords: false,
hideStudentInfo: true
})
const originalData = ref({})
// 当前限额类型和值的快捷访问
const currentLimitType = computed(() => {
if (formData.value.monthlySubmissionLimit !== null) return 'monthly'
return formData.value.weeklySubmissionLimit !== null ? 'weekly' : 'daily'
})
const currentLimitValue = computed({
get: () => {
if (currentLimitType.value === 'monthly') return formData.value.monthlySubmissionLimit
return currentLimitType.value === 'daily'
? formData.value.dailySubmissionLimit
: formData.value.weeklySubmissionLimit
},
set: (val) => {
if (currentLimitType.value === 'monthly') {
formData.value.monthlySubmissionLimit = val
} else if (currentLimitType.value === 'daily') {
formData.value.dailySubmissionLimit = val
} else {
formData.value.weeklySubmissionLimit = val
}
}
})
// 加载配置
const loadConfig = async () => {
try {
loading.value = true
const response = await fetch('/api/admin/system-settings', {
credentials: 'include'
})
if (!response.ok) throw new Error('获取配置失败')
const data = await response.json()
formData.value = {
siteTitle: data.siteTitle || '',
siteLogoUrl: data.siteLogoUrl || '',
schoolLogoHomeUrl: data.schoolLogoHomeUrl || '',
schoolLogoPrintUrl: data.schoolLogoPrintUrl || '',
siteDescription: data.siteDescription || '',
submissionGuidelines: data.submissionGuidelines || defaultSubmissionGuidelines,
icpNumber: data.icpNumber || '',
gonganNumber: data.gonganNumber || '',
enableCollaborativeSubmission: data.enableCollaborativeSubmission !== false,
enableSubmissionRemarks: !!data.enableSubmissionRemarks,
enableReplayRequests: !!data.enableReplayRequests,
enableSubmissionLimit: !!data.enableSubmissionLimit,
dailySubmissionLimit: data.dailySubmissionLimit ?? 5,
weeklySubmissionLimit: data.weeklySubmissionLimit ?? null,
monthlySubmissionLimit: data.monthlySubmissionLimit ?? null,
showBlacklistKeywords: !!data.showBlacklistKeywords,
hideStudentInfo: data.hideStudentInfo ?? true
}
originalData.value = JSON.parse(JSON.stringify(formData.value))
} catch (error) {
console.error('加载配置失败:', error)
showNotification('加载配置失败', 'error')
} finally {
loading.value = false
}
}
// 保存配置
const saveConfig = async () => {
try {
saving.value = true
const configToSave = {
...formData.value,
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/favicon.ico',
submissionGuidelines:
(formData.value.submissionGuidelines || '').trim() || defaultSubmissionGuidelines,
// 确保根据限额类型处理空值
dailySubmissionLimit:
currentLimitType.value === 'daily' ? formData.value.dailySubmissionLimit : null,
weeklySubmissionLimit:
currentLimitType.value === 'weekly' ? formData.value.weeklySubmissionLimit : null,
monthlySubmissionLimit:
currentLimitType.value === 'monthly' ? formData.value.monthlySubmissionLimit : null
}
const response = await fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(configToSave)
})
if (!response.ok) throw new Error('保存配置失败')
saveSuccess.value = true
originalData.value = JSON.parse(JSON.stringify(formData.value))
showNotification('配置保存成功!', 'success')
setTimeout(() => {
saveSuccess.value = false
}, 3000)
} catch (error) {
console.error('保存配置失败:', error)
showNotification('保存配置失败,请重试', 'error')
} finally {
saving.value = false
}
}
// 处理限额类型变化
const handleLimitTypeChange = (type) => {
const limits = {
daily: { key: 'dailySubmissionLimit', default: 5 },
weekly: { key: 'weeklySubmissionLimit', default: 20 },
monthly: { key: 'monthlySubmissionLimit', default: 50 }
}
// 将非当前类型的限额设为 null
Object.keys(limits).forEach((limitType) => {
if (limitType !== type) {
formData.value[limits[limitType].key] = null
}
})
// 如果当前类型的限额为 null,则设置默认值
const targetLimit = limits[type]
if (formData.value[targetLimit.key] === null) {
formData.value[targetLimit.key] = targetLimit.default
}
}
// 重置表单
const resetForm = () => {
formData.value = JSON.parse(JSON.stringify(originalData.value))
}
onMounted(loadConfig)
</script>
<style scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
</style>
@@ -0,0 +1,377 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-10 pb-20 px-2">
<!-- 头部区域 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">邮件服务配置</h2>
<p class="text-xs text-zinc-500 mt-1">
配置 SMTP 服务以发送系统验证码、投稿通知及其他重要提醒
</p>
</div>
<div class="flex items-center gap-3">
<button
class="flex items-center gap-2 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
:disabled="reloading"
@click="reloadSmtpConfig"
>
<RotateCw :size="14" :class="reloading ? 'animate-spin' : ''" />
{{ reloading ? '重载中...' : '重载SMTP' }}
</button>
<button
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
:disabled="saving"
@click="saveConfig"
>
<Save :size="14" /> {{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
<!-- 左侧栏:SMTP 设置与测试 -->
<div class="xl:col-span-4 space-y-8">
<!-- SMTP 核心设置 -->
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
<div class="flex items-center justify-between">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<Server :size="16" class="text-blue-500" /> 服务配置
</h3>
<button
class="relative w-10 h-5 rounded-full transition-colors"
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-800'"
@click="config.smtpEnabled = !config.smtpEnabled"
>
<div
class="absolute top-1 w-3 h-3 bg-white rounded-full transition-all"
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
/>
</button>
</div>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>SMTP 服务器</label
>
<input
v-model="config.smtpHost"
type="text"
placeholder="smtp.example.com"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>端口</label
>
<input
v-model.number="config.smtpPort"
type="number"
placeholder="587"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>加密方式</label
>
<CustomSelect
:model-value="config.smtpSecure ? 'SSL/TLS' : '无'"
:options="['SSL/TLS', '无']"
class="w-full"
@update:model-value="(val) => (config.smtpSecure = val === 'SSL/TLS')"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人账号</label
>
<input
v-model="config.smtpUsername"
type="text"
placeholder="your-email@example.com"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人邮箱 (From Email)</label
>
<input
v-model="config.smtpFromEmail"
type="text"
:placeholder="config.smtpUsername || 'your-email@example.com'"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
<p class="text-[9px] text-zinc-500 px-1 italic">
通常与发件人账号一致,部分服务商强制要求一致。
</p>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>授权码 / 密码</label
>
<input
v-model="config.smtpPassword"
type="password"
placeholder="••••••••••••"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人姓名</label
>
<input
v-model="config.smtpFromName"
type="text"
placeholder="校园广播站"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
</section>
<!-- SMTP 测试模块 -->
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<Send :size="16" class="text-emerald-500" /> 服务测试
</h3>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>测试接收邮箱</label
>
<div class="flex gap-2">
<input
v-model="testEmail"
type="email"
placeholder="输入测试邮箱地址"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="testing || !testEmail || !config.smtpEnabled"
@click="sendTestEmail"
>
<Check v-if="testResult?.success" :size="14" class="text-emerald-500" />
<Send v-else :size="14" />
{{ testing ? '发送中' : testResult?.success ? '已发送' : '发送' }}
</button>
</div>
</div>
<div v-if="testResult">
<div
class="flex items-center gap-2 p-3 rounded-xl text-xs"
:class="
testResult.success
? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
: 'bg-red-500/10 text-red-500 border border-red-500/20'
"
>
<CheckCircle v-if="testResult.success" :size="14" />
<XCircle v-else :size="14" />
<div class="flex flex-col gap-0.5">
<p class="font-bold">{{ testResult.message }}</p>
<p v-if="testResult.detail" class="text-[10px] opacity-80 break-all font-mono">
{{ testResult.detail }}
</p>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- 右侧栏:模板编辑器 -->
<div class="xl:col-span-8">
<EmailTemplateManager />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import EmailTemplateManager from '~/components/Admin/EmailTemplateManager.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { Server, Save, Check, Send, CheckCircle, XCircle, RotateCw } from 'lucide-vue-next'
const { showToast: showNotification } = useToast()
// 响应式数据
const config = ref({
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUsername: '',
smtpPassword: '',
smtpFromEmail: '',
smtpFromName: '校园广播站'
})
const testEmail = ref('')
const testing = ref(false)
const saving = ref(false)
const reloading = ref(false)
const testResult = ref(null)
const originalConfig = ref({})
// 加载配置
const loadConfig = async () => {
try {
const response = await $fetch('/api/admin/system-settings')
config.value = {
smtpEnabled: response.smtpEnabled || false,
smtpHost: response.smtpHost || '',
smtpPort: response.smtpPort || 587,
smtpSecure: response.smtpSecure || false,
smtpUsername: response.smtpUsername || '',
smtpPassword: response.smtpPassword || '',
smtpFromEmail: response.smtpFromEmail || '',
smtpFromName: response.smtpFromName || '校园广播站'
}
// 保存原始配置用于重置
originalConfig.value = { ...config.value }
} catch (error) {
console.error('加载SMTP配置失败:', error)
showNotification('加载配置失败', 'error')
}
}
// 保存配置
const saveConfig = async () => {
if (config.value.smtpEnabled) {
// 验证必填字段
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
showNotification('请填写完整的SMTP配置信息', 'error')
return
}
}
saving.value = true
try {
await $fetch('/api/admin/system-settings', {
method: 'POST',
body: config.value
})
originalConfig.value = { ...config.value }
showNotification('SMTP配置保存成功', 'success')
} catch (error) {
console.error('保存SMTP配置失败:', error)
showNotification(error.data?.message || '保存配置失败', 'error')
} finally {
saving.value = false
}
}
// 重置配置
const resetConfig = () => {
config.value = { ...originalConfig.value }
testResult.value = null
showNotification('配置已重置', 'info')
}
const reloadSmtpConfig = async () => {
reloading.value = true
try {
const response = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!response.success) {
throw new Error(response.message || 'SMTP重载失败')
}
showNotification(response.message || 'SMTP配置已重载', 'success')
} catch (error) {
console.error('重载SMTP配置失败:', error)
showNotification(error.data?.message || error.message || '重载SMTP配置失败', 'error')
} finally {
reloading.value = false
}
}
// 测试连接
const testConnection = async () => {
if (!config.value.smtpEnabled || !config.value.smtpHost) {
showNotification('请先配置SMTP服务器信息', 'error')
return
}
testing.value = true
testResult.value = null
try {
const response = await $fetch('/api/admin/smtp/test-connection', {
method: 'POST',
body: config.value
})
testResult.value = response
} catch (error) {
console.error('测试连接失败:', error)
testResult.value = {
success: false,
message: error.data?.message || '测试连接失败'
}
} finally {
testing.value = false
}
}
// 发送测试邮件
const sendTestEmail = async () => {
if (!testEmail.value) {
showNotification('请输入测试邮箱地址', 'error')
return
}
testing.value = true
testResult.value = null
try {
const response = await $fetch('/api/admin/smtp/test-email', {
method: 'POST',
body: {
...config.value,
testEmail: testEmail.value
}
})
testResult.value = response
if (response.success) {
showNotification('测试邮件发送成功', 'success')
setTimeout(() => {
testResult.value = null
}, 5000)
}
} catch (error) {
console.error('发送测试邮件失败:', error)
testResult.value = {
success: false,
message: error.data?.message || '发送测试邮件失败'
}
} finally {
testing.value = false
}
}
// 生命周期
onMounted(() => {
loadConfig()
})
</script>
<style scoped></style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
@click="close"
>
<div
class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">投稿备注留言</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="close">
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-4">
<div class="flex items-center gap-3">
<p class="text-xs text-zinc-500 font-medium">{{ songTitle }}</p>
<span
:class="[
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border',
isPublic
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
]"
>
{{ isPublic ? '公开备注' : '仅管理员可见' }}
</span>
</div>
<div class="bg-zinc-950/50 border border-zinc-800/50 rounded-xl p-4">
<p class="text-sm text-zinc-200 leading-relaxed whitespace-pre-wrap">
{{ content }}
</p>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { X } from 'lucide-vue-next'
defineProps({
show: {
type: Boolean,
default: false
},
songTitle: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
isPublic: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['close'])
const close = () => {
emit('close')
}
</script>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,483 @@
<template>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click="handleOverlayClick"
>
<!-- 遮罩层 -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<!-- 模态框面板 -->
<transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 scale-95 translate-y-4"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-4"
>
<div
v-if="show"
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50 backdrop-blur-sm"
>
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
<button
class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
@click="$emit('close')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 18 12" />
</svg>
</button>
</div>
<!-- 主体内容 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-zinc-900">
<!-- 加载状态 -->
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
<LoadingState message="加载数据中..." spinner-type="circle" />
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-4"
>
<div class="text-4xl">⚠️</div>
<p>{{ error }}</p>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm font-medium"
@click="retryFetch"
>
重试
</button>
</div>
<!-- 内容 -->
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
<!-- 用户信息头部 -->
<div class="px-6 py-4 bg-zinc-900 border-b border-zinc-800">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<h4 class="text-base font-medium text-white">{{ userSongs.user.name }}</h4>
<span class="text-sm text-zinc-500 font-mono">{{
userSongs.user.username
}}</span>
</div>
<p
v-if="userSongs.user.grade || userSongs.user.class"
class="text-sm text-zinc-400"
>
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
</p>
</div>
</div>
<!-- 工具栏 (标签页 + 过滤) -->
<div
class="flex flex-col sm:flex-row items-center justify-between border-b border-zinc-800 bg-zinc-900/50"
>
<!-- 标签页 -->
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
<button
v-for="tab in tabs"
:key="tab.id"
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
:class="
activeTab === tab.id
? 'text-blue-500'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
"
@click="activeTab = tab.id"
>
{{ tab.label }}
<span
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
:class="
activeTab === tab.id
? 'bg-blue-500/10 text-blue-500'
: 'bg-zinc-800 text-zinc-500'
"
>
{{ getTabCount(tab.id) }}
</span>
<!-- 激活指示器 -->
<div
v-if="activeTab === tab.id"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500"
/>
</button>
</div>
<!-- 学期过滤器 -->
<div
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-zinc-800 flex items-center gap-3 bg-zinc-900/30"
>
<CustomSelect
v-model="selectedSemester"
:options="[{ label: '全部学期', value: null }, ...semesterOptions]"
label="学期过滤"
placeholder="全部学期"
class-name="w-full sm:w-48"
/>
</div>
</div>
<!-- 歌曲列表 -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3 custom-scrollbar">
<div
v-if="filteredSongs.length === 0"
class="flex flex-col items-center justify-center py-20 text-zinc-500"
>
<div class="text-5xl mb-4 opacity-50">{{ activeTabIcon }}</div>
<p>没有找到相关记录</p>
</div>
<div
v-for="song in filteredSongs"
:key="song.id"
class="group flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-zinc-800 bg-zinc-800/20 hover:bg-zinc-800/50 hover:border-zinc-700 transition-all duration-200"
>
<!-- 歌曲信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h5 class="text-sm font-medium text-white truncate">{{ song.title }}</h5>
<span
v-if="song.semester"
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-800 text-zinc-400 border border-zinc-700"
>
{{ song.semester }}
</span>
</div>
<div class="text-sm text-zinc-400 truncate">{{ song.artist }}</div>
<!-- Meta Info (Mobile) -->
<div
class="flex flex-wrap items-center gap-3 mt-3 sm:hidden text-xs text-zinc-500"
>
<span>{{ getMetaTime(song) }}</span>
<span v-if="song.voteCount !== undefined">{{ song.voteCount }} 票</span>
<span v-if="song.requestCount !== undefined"
>{{ song.requestCount }} 人申请</span
>
</div>
</div>
<!-- 状态与元数据 (桌面端) -->
<div class="flex items-center gap-4 sm:gap-6">
<!-- 状态标签 -->
<div class="flex-shrink-0">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium border"
:class="getStatusClasses(song)"
>
{{ getStatusText(song) }}
</span>
</div>
<!-- Meta Info (Desktop) -->
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
<span class="text-xs text-zinc-400">{{ getMetaTime(song) }}</span>
<div class="flex items-center gap-2">
<span v-if="song.voteCount !== undefined" class="text-xs text-zinc-500"
>{{ song.voteCount }} 票</span
>
<span v-if="song.requestCount !== undefined" class="text-xs text-zinc-500"
>{{ song.requestCount }} 人申请</span
>
</div>
</div>
<!-- Submitter Info (if available) -->
<div v-if="song.requester" class="hidden sm:block text-right min-w-[80px]">
<div class="text-xs text-zinc-300">{{ song.requester.name }}</div>
<div class="text-[10px] text-zinc-500">
{{ song.requester.grade }}{{ song.requester.class }}
</div>
</div>
</div>
<!-- 投稿人信息 (移动端) -->
<div
v-if="song.requester"
class="sm:hidden pt-3 mt-1 border-t border-zinc-800/50 flex justify-between items-center text-xs"
>
<span class="text-zinc-500">投稿人:</span>
<span class="text-zinc-300">
{{ song.requester.name }}
<span class="text-zinc-500 ml-1">
{{ song.requester.grade }}{{ song.requester.class }}
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useSemesters } from '~/composables/useSemesters'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
userId: {
type: Number,
default: null
}
})
const emit = defineEmits(['close'])
// Data
const loading = ref(false)
const error = ref('')
const userSongs = ref(null)
const activeTab = ref('submitted')
const selectedSemester = ref(null)
// Composables
const { semesters, fetchSemesterOptions } = useSemesters()
// Computed
const semesterOptions = computed(() => {
return semesters.value.map((s) => ({
label: s.name,
value: s.name
}))
})
const tabs = [
{ id: 'submitted', label: '投稿歌曲' },
{ id: 'voted', label: '投票歌曲' },
{ id: 'replay', label: '重播申请' }
]
const activeTabIcon = computed(() => {
switch (activeTab.value) {
case 'submitted':
return '🎵'
case 'voted':
return '❤️'
case 'replay':
return '🔁'
default:
return '📄'
}
})
const currentSongs = computed(() => {
if (!userSongs.value) return []
switch (activeTab.value) {
case 'submitted':
return userSongs.value.submittedSongs || []
case 'voted':
return userSongs.value.votedSongs || []
case 'replay':
return userSongs.value.replayRequestedSongs || []
default:
return []
}
})
const filteredSongs = computed(() => {
let songs = currentSongs.value
if (selectedSemester.value) {
songs = songs.filter((song) => song.semester === selectedSemester.value)
}
return songs
})
// Methods
const getTabCount = (tabId) => {
if (!userSongs.value) return 0
let songs = []
switch (tabId) {
case 'submitted':
songs = userSongs.value.submittedSongs || []
break
case 'voted':
songs = userSongs.value.votedSongs || []
break
case 'replay':
songs = userSongs.value.replayRequestedSongs || []
break
default:
return 0
}
if (selectedSemester.value) {
songs = songs.filter((song) => song.semester === selectedSemester.value)
}
return songs.length
}
const fetchUserSongs = async () => {
if (!props.userId) return
loading.value = true
error.value = ''
try {
const auth = useAuth()
// 并行获取歌曲数据和学期列表
const promises = [$fetch(`/api/admin/users/${props.userId}/songs`, { ...auth.getAuthConfig() })]
// 如果学期列表为空,尝试获取
if (semesters.value.length === 0) {
promises.push(fetchSemesterOptions())
}
const [songsResponse] = await Promise.all(promises)
userSongs.value = songsResponse
} catch (err) {
console.error('获取用户歌曲信息失败:', err)
error.value = err.data?.message || '获取用户歌曲信息失败'
} finally {
loading.value = false
}
}
const retryFetch = () => {
fetchUserSongs()
}
const handleOverlayClick = () => {
emit('close')
}
// Helpers
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 86400000 * 7) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getMetaTime = (song) => {
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} 投票`
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} 申请`
return ''
}
const getStatusText = (song) => {
if (song.played) return '已播放'
if (song.scheduled) return '已排期'
return '待排期'
}
const getStatusClasses = (song) => {
if (song.played) {
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
}
if (song.scheduled) {
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
}
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}
// 监听器
watch(
() => props.userId,
(newUserId) => {
if (newUserId && props.show) {
fetchUserSongs()
}
}
)
watch(
() => props.show,
(newShow) => {
if (newShow && props.userId) {
fetchUserSongs()
} else if (!newShow) {
// 关闭时重置状态
userSongs.value = null
error.value = ''
activeTab.value = 'submitted'
selectedSemester.value = null // 可选:关闭时重置过滤器
}
}
)
</script>
<style scoped>
/* 列表的自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(113, 113, 122, 0.3); /* zinc-500/30 */
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(113, 113, 122, 0.5);
}
/* 标签页隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
@@ -0,0 +1,533 @@
<template>
<div v-if="show" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3 class="modal-title">投票人员列表</h3>
<button class="close-btn" @click="closeModal">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
<div class="modal-body">
<!-- 歌曲信息 -->
<div v-if="songInfo" class="song-info">
<h4 class="song-title">{{ songInfo.title }}</h4>
<p class="song-artist">{{ songInfo.artist }}</p>
<div class="vote-summary">
<svg class="heart-icon" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
/>
</svg>
<span class="vote-count">{{ totalVotes }} 票</span>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="spinner" />
<p>正在加载投票人员...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<svg
class="error-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
<p class="error-message">{{ error }}</p>
<button class="retry-btn" @click="fetchVoters">重试</button>
</div>
<!-- 投票人员列表 -->
<div v-else-if="voters.length > 0" class="voters-list">
<div class="voters-header">
<span class="voters-title">投票人员 ({{ voters.length }})</span>
</div>
<div class="voters-container">
<div v-for="(voter, index) in voters" :key="voter.id" class="voter-item">
<div class="voter-info">
<div class="voter-avatar">
{{ getAvatarText(voter.name) }}
</div>
<div class="voter-details">
<span class="voter-name">{{ voter.name }}</span>
<span class="vote-time">{{ formatVoteTime(voter.votedAt) }}</span>
</div>
</div>
<div class="voter-number">#{{ index + 1 }}</div>
</div>
</div>
</div>
<!-- 无投票状态 -->
<div v-else class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
/>
</svg>
<p>暂无投票</p>
</div>
</div>
<div class="modal-footer">
<button class="close-button" @click="closeModal">关闭</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
songId: {
type: Number,
default: null
}
})
// Emits
const emit = defineEmits(['close'])
// 响应式数据
const loading = ref(false)
const error = ref('')
const songInfo = ref(null)
const voters = ref([])
const totalVotes = ref(0)
// 方法
const closeModal = () => {
emit('close')
}
const fetchVoters = async () => {
if (!props.songId) return
loading.value = true
error.value = ''
try {
const response = await $fetch(`/api/songs/${props.songId}/voters`)
songInfo.value = response.song
voters.value = response.voters || []
totalVotes.value = response.totalVotes || 0
} catch (err) {
console.error('获取投票人员失败:', err)
error.value = err.data?.message || '获取投票人员失败'
} finally {
loading.value = false
}
}
const getAvatarText = (name) => {
if (!name) return '?'
// 提取中文姓名的最后一个字符,或英文名的首字母
const cleanName = name.replace(/[((].*[))]/, '').trim()
return cleanName.slice(-1).toUpperCase()
}
const formatVoteTime = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 监听弹窗显示状态
watch(
() => props.show,
(newShow) => {
if (newShow && props.songId) {
fetchVoters()
} else {
// 重置数据
songInfo.value = null
voters.value = []
totalVotes.value = 0
error.value = ''
}
}
)
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #1a1a1a;
border-radius: 16px;
border: 1px solid #2a2a2a;
width: 100%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: #ffffff;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #2a2a2a;
color: #ffffff;
}
.close-btn svg {
width: 20px;
height: 20px;
}
.modal-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.song-info {
background: #2a2a2a;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid #3a3a3a;
}
.song-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
margin: 0 0 8px 0;
}
.song-artist {
color: #888;
margin: 0 0 16px 0;
font-size: 14px;
}
.vote-summary {
display: flex;
align-items: center;
gap: 8px;
}
.heart-icon {
width: 20px;
height: 20px;
color: #ef4444;
}
.vote-count {
color: #ef4444;
font-weight: 600;
font-size: 16px;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #3a3a3a;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.error-icon {
width: 48px;
height: 48px;
color: #ef4444;
}
.error-message {
color: #ef4444;
text-align: center;
margin: 0;
}
.retry-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s ease;
}
.retry-btn:hover {
background: #5a67d8;
}
.voters-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.voters-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #3a3a3a;
}
.voters-title {
font-weight: 600;
color: #ffffff;
font-size: 16px;
}
.voters-container {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
}
.voter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #2a2a2a;
border-radius: 8px;
border: 1px solid #3a3a3a;
transition: all 0.2s ease;
}
.voter-item:hover {
background: #333333;
border-color: #4a4a4a;
}
.voter-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.voter-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.voter-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.voter-name {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
.vote-time {
color: #888;
font-size: 12px;
}
.voter-number {
color: #888;
font-size: 12px;
font-weight: 500;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.empty-icon {
width: 48px;
height: 48px;
color: #666;
}
.empty-state p {
color: #888;
margin: 0;
}
.modal-footer {
padding: 0 24px 24px;
display: flex;
justify-content: flex-end;
}
.close-button {
background: #3a3a3a;
color: #ffffff;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s ease;
}
.close-button:hover {
background: #4a4a4a;
}
/* 滚动条样式 */
.voters-container::-webkit-scrollbar {
width: 6px;
}
.voters-container::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 3px;
}
.voters-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.voters-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
/* 响应式设计 */
@media (max-width: 768px) {
.modal-overlay {
padding: 10px;
}
.modal-content {
max-height: 90vh;
}
.modal-header,
.modal-body,
.modal-footer {
padding-left: 16px;
padding-right: 16px;
}
.voter-item {
padding: 10px 12px;
}
.voter-avatar {
width: 32px;
height: 32px;
font-size: 12px;
}
}
</style>