feat: 迁移自定义功能至 1.5.9.9 版本

包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
2026-09-12 19:26:42 +08:00
parent 34f0679117
commit 1106aeea6c
161 changed files with 18963 additions and 27900 deletions
+215
View File
@@ -0,0 +1,215 @@
<template>
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<div class="flex items-center" @touchstart.stop>
<button
:disabled="isFirstDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('left')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div
ref="dateSelector"
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
style="overscroll-behavior-x: contain; touch-action: pan-x"
@wheel="handleWheel"
@scroll="handleScroll"
>
<button
v-for="date in availableDates"
:key="date.value"
:data-date="date.value"
:class="[
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
modelValue === date.value
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700'
]"
@click="selectDate(date.value)"
>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
</button>
</div>
<button
:disabled="isLastDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('right')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
<!-- 操作按钮组 -->
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
<CircleDot class="w-5 h-5" />
</button>
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
<CalendarIcon class="w-5 h-5" />
</button>
</div>
</div>
<!-- 手动日期选择弹窗 -->
<div
v-if="showManualDatePicker"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
<CloseIcon class="w-5 h-5" />
</button>
</div>
<div class="p-6 space-y-6">
<input
v-model="manualSelectedDate"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-blue-500 transition-colors"
type="date"
>
<div class="flex gap-3">
<button class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider" @click="showManualDatePicker = false">取消</button>
<button class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider" @click="confirmManualDate">确认</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { CircleDot, Calendar as CalendarIcon, X as CloseIcon } from '@lucide/vue'
const props = defineProps({
modelValue: {
type: String,
required: true
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const dateSelector = ref(null)
const isFirstDateVisible = ref(false)
const isLastDateVisible = ref(false)
const dateRange = ref({ start: -15, end: 15 })
const showManualDatePicker = ref(false)
const manualSelectedDate = ref('')
const availableDates = computed(() => {
const dates = []
const today = new Date()
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
const dateStr = date.toISOString().split('T')[0]
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
dates.push({
value: dateStr,
day: date.getDate().toString().padStart(2, '0'),
month: (date.getMonth() + 1).toString().padStart(2, '0'),
weekday: weekdays[date.getDay()],
isToday: i === 0
})
}
return dates
})
const selectDate = (dateValue) => {
emit('update:modelValue', dateValue)
emit('change', dateValue)
}
const scrollToDateElement = (behavior = 'smooth') => {
if (!dateSelector.value || !props.modelValue) return
const el = dateSelector.value.querySelector(`[data-date="${props.modelValue}"]`)
if (el) {
el.scrollIntoView({ behavior, block: 'nearest', inline: 'center' })
}
}
const handleWheel = (event) => {
event.preventDefault()
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const scrollDates = (direction) => {
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const handleScroll = () => {
if (!dateSelector.value) return
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
if (scrollLeft < 50) {
const oldScrollWidth = scrollWidth
dateRange.value.start -= 7
nextTick(() => {
const newScrollWidth = dateSelector.value.scrollWidth
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
})
}
if (scrollWidth - scrollLeft - clientWidth < 50) {
dateRange.value.end += 7
}
}
const scrollToToday = () => {
const todayStr = new Date().toISOString().split('T')[0]
if (props.modelValue !== todayStr) {
selectDate(todayStr)
}
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
dateRange.value.start = -15
dateRange.value.end = 15
}
nextTick(() => scrollToDateElement('smooth'))
}
const openManualDatePicker = () => {
manualSelectedDate.value = props.modelValue
showManualDatePicker.value = true
}
const confirmManualDate = () => {
if (manualSelectedDate.value && manualSelectedDate.value !== props.modelValue) {
selectDate(manualSelectedDate.value)
}
showManualDatePicker.value = false
nextTick(() => scrollToDateElement('smooth'))
}
watch(() => props.modelValue, () => {
nextTick(() => scrollToDateElement('smooth'))
})
onMounted(() => {
nextTick(() => scrollToDateElement('auto'))
})
</script>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
@@ -0,0 +1,226 @@
<template>
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<div class="flex items-center" @touchstart.stop>
<button
:disabled="isFirstDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('left')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div
ref="dateSelector"
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
style="overscroll-behavior-x: contain; touch-action: pan-x"
@wheel="handleWheel"
@scroll="handleScroll"
>
<button
v-for="date in availableDates"
:key="date.value"
:data-date="date.value"
:class="[
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
modelValue.includes(date.value)
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700'
]"
@click="toggleDate(date.value)"
>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
</button>
</div>
<button
:disabled="isLastDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('right')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
<!-- 操作按钮组 -->
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
<CircleDot class="w-5 h-5" />
</button>
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
<CalendarIcon class="w-5 h-5" />
</button>
</div>
</div>
<!-- 手动日期选择弹窗 -->
<div
v-if="showManualDatePicker"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
<CloseIcon class="w-5 h-5" />
</button>
</div>
<div class="p-6 space-y-6">
<input
v-model="manualSelectedDate"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-blue-500 transition-colors"
type="date"
>
<div class="flex gap-3">
<button class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider" @click="showManualDatePicker = false">取消</button>
<button class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider" @click="confirmManualDate">确认添加</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { CircleDot, Calendar as CalendarIcon, X as CloseIcon } from '@lucide/vue'
const props = defineProps({
modelValue: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue', 'change'])
const dateSelector = ref(null)
const isFirstDateVisible = ref(false)
const isLastDateVisible = ref(false)
const dateRange = ref({ start: 0, end: 30 }) // 默认展示今天起30天
const showManualDatePicker = ref(false)
const manualSelectedDate = ref('')
const availableDates = computed(() => {
const dates = []
const today = new Date()
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
const dateStr = date.toISOString().split('T')[0]
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
dates.push({
value: dateStr,
day: date.getDate().toString().padStart(2, '0'),
month: (date.getMonth() + 1).toString().padStart(2, '0'),
weekday: weekdays[date.getDay()],
isToday: i === 0
})
}
return dates
})
const toggleDate = (dateValue) => {
const newValue = [...props.modelValue]
const index = newValue.indexOf(dateValue)
if (index > -1) {
newValue.splice(index, 1)
} else {
newValue.push(dateValue)
newValue.sort() // 保持日期顺序
}
emit('update:modelValue', newValue)
emit('change', newValue)
}
const handleWheel = (event) => {
event.preventDefault()
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const scrollDates = (direction) => {
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const handleScroll = () => {
if (!dateSelector.value) return
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
if (scrollLeft < 50) {
const oldScrollWidth = scrollWidth
dateRange.value.start -= 7
nextTick(() => {
const newScrollWidth = dateSelector.value.scrollWidth
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
})
}
if (scrollWidth - scrollLeft - clientWidth < 50) {
dateRange.value.end += 7
}
}
const scrollToToday = () => {
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
dateRange.value.start = -15
dateRange.value.end = 15
}
nextTick(() => {
if (!dateSelector.value) return
const todayStr = new Date().toISOString().split('T')[0]
const el = dateSelector.value.querySelector(`[data-date="${todayStr}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
})
}
const openManualDatePicker = () => {
manualSelectedDate.value = new Date().toISOString().split('T')[0]
showManualDatePicker.value = true
}
const confirmManualDate = () => {
if (manualSelectedDate.value) {
const newValue = [...props.modelValue]
if (!newValue.includes(manualSelectedDate.value)) {
newValue.push(manualSelectedDate.value)
newValue.sort()
emit('update:modelValue', newValue)
emit('change', newValue)
}
}
showManualDatePicker.value = false
nextTick(() => {
if (!dateSelector.value) return
const el = dateSelector.value.querySelector(`[data-date="${manualSelectedDate.value}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
})
}
onMounted(() => {
nextTick(() => scrollToToday())
})
</script>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
@@ -0,0 +1,144 @@
<template>
<Teleport to="body">
<div
v-if="visible"
class="fixed inset-0 z-[70] bg-black/70 backdrop-blur-sm px-4 py-6 flex items-end sm:items-center justify-center"
>
<div
class="w-full max-w-xl rounded-2xl border border-zinc-800 bg-zinc-950 shadow-2xl overflow-hidden"
>
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between gap-3">
<div>
<p class="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black">后台引导</p>
<h3 class="text-lg font-black text-zinc-100 mt-1">{{ currentStep.title }}</h3>
</div>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
@click="handleClose"
>
<X :size="16" class="mx-auto" />
</button>
</div>
<div class="px-5 py-5 space-y-4">
<p class="text-sm leading-relaxed text-zinc-300">{{ currentStep.description }}</p>
<div class="rounded-xl border border-blue-500/20 bg-blue-500/5 p-3">
<p class="text-xs text-zinc-400">
建议操作:
<span class="text-zinc-200 font-semibold">{{ currentStep.actionHint }}</span>
</p>
</div>
</div>
<div
class="px-5 py-4 border-t border-zinc-800 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<p class="text-xs text-zinc-500 font-semibold">{{ stepIndex + 1 }} / {{ steps.length }}</p>
<div class="flex items-center gap-2">
<button
v-if="stepIndex > 0"
class="px-3.5 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm"
@click="prevStep"
>
上一步
</button>
<button
class="px-3.5 py-2 rounded-lg border border-zinc-700 text-zinc-200 hover:bg-zinc-900 text-sm"
@click="goCurrentStep"
>
前往此功能
</button>
<button
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold"
@click="nextStep"
>
{{ stepIndex === steps.length - 1 ? '完成引导' : '下一步' }}
</button>
</div>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { X } from '@lucide/vue'
const props = defineProps({
visible: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['navigate', 'close', 'complete'])
const steps = [
{
tab: 'overview',
title: '先看数据概览',
description: '这里会显示待处理歌曲、播放数据和系统运行状态,建议每次值班先看这一页。',
actionHint: '进入“数据概览”快速确认今日状态。'
},
{
tab: 'songs',
title: '处理歌曲审核',
description: '在歌曲管理中可试听、拒绝或保留歌曲,确保内容合规后再进入排期流程。',
actionHint: '优先处理新提交和被举报的歌曲。'
},
{
tab: 'schedule',
title: '安排节目排期',
description: '将审核通过的歌曲拖拽到目标日期与时段,调整顺序后发布,用户即可看到最新排期。',
actionHint: '排期完成后记得点击发布,避免用户看到旧数据。'
},
{
tab: 'site-config',
title: '维护站点配置',
description: '可统一修改站点标题、版本号和投稿规则,保存后会对前台页面立即生效。',
actionHint: '发布新版本时,建议同步更新“站点版本号”。'
},
{
tab: 'workflow-tutorial',
title: '查看完整流程教程',
description: '如果需要标准值班流程和FAQ,可在“流程与教程”页面查看详细图文说明。',
actionHint: '把该页面作为新人培训材料。'
}
]
const stepIndex = ref(0)
const currentStep = computed(() => steps[stepIndex.value])
watch(
() => props.visible,
(visible) => {
if (visible) {
stepIndex.value = 0
}
}
)
const goCurrentStep = () => {
emit('navigate', currentStep.value.tab)
}
const prevStep = () => {
if (stepIndex.value > 0) {
stepIndex.value -= 1
}
}
const nextStep = () => {
goCurrentStep()
if (stepIndex.value < steps.length - 1) {
stepIndex.value += 1
return
}
emit('complete')
}
const handleClose = () => {
emit('close')
}
</script>
File diff suppressed because it is too large Load Diff
+136 -141
View File
@@ -2,8 +2,8 @@
<div class="backup-manager">
<!-- 标题 -->
<div class="header">
<h3>{{ locale.title }}</h3>
<p class="description">{{ locale.desc }}</p>
<h3>数据库备份</h3>
<p class="description">导出和导入数据库备份</p>
</div>
<!-- 主要功能区 -->
@@ -17,15 +17,15 @@
</svg>
</div>
<div class="card-content">
<h4>{{ locale.exportTitle }}</h4>
<p>{{ locale.exportDesc }}</p>
<h4>导出备份</h4>
<p>创建数据库备份文件</p>
<button
:disabled="createLoading"
class="action-btn primary"
@click="showCreateModal = true"
>
<span v-if="createLoading">{{ locale.exporting }}</span>
<span v-else>{{ locale.startExport }}</span>
<span v-if="createLoading">导出中...</span>
<span v-else>开始导出</span>
</button>
</div>
</div>
@@ -39,25 +39,25 @@
</svg>
</div>
<div class="card-content">
<h4>{{ locale.importTitle }}</h4>
<p>{{ locale.importDesc }}</p>
<h4>导入备份</h4>
<p>从备份文件恢复数据</p>
<button
:disabled="uploadLoading"
class="action-btn secondary"
@click="showUploadModal = true"
>
<span v-if="uploadLoading">{{ locale.importing }}</span>
<span v-else>{{ locale.chooseFile }}</span>
<span v-if="uploadLoading">导入中...</span>
<span v-else>选择文件</span>
</button>
</div>
</div>
</div>
<!-- 创建备份模态框 -->
<div v-if="showCreateModal" class="backupmanager-modal-overlay" @click="showCreateModal = false">
<div v-if="showCreateModal" class="modal-overlay" @click="showCreateModal = false">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>{{ locale.createTitle }}</h3>
<h3>创建数据库备份</h3>
<button class="close-btn" @click="showCreateModal = false">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
@@ -67,43 +67,43 @@
</div>
<div class="modal-body">
<div class="form-group">
<label>{{ locale.backupType }}</label>
<label>备份类型</label>
<div class="radio-group">
<label class="radio-option">
<input v-model="createForm.tables" type="radio" value="all" >
<span>{{ locale.fullBackup }}</span>
<small>{{ locale.fullBackupDesc }}</small>
<span>完整备份(推荐)</span>
<small>备份所有数据表</small>
</label>
<label class="radio-option">
<input v-model="createForm.tables" type="radio" value="users" >
<span>{{ locale.userDataOnly }}</span>
<small>{{ locale.userDataOnlyDesc }}</small>
<span>仅用户数据</span>
<small>只备份用户相关数据</small>
</label>
</div>
</div>
<div class="form-group">
<label class="checkbox-option">
<input v-model="createForm.includeSystemData" type="checkbox" >
<span>{{ locale.includeSystemSettings }}</span>
<small>{{ locale.includeSystemSettingsDesc }}</small>
<span>包含系统设置</span>
<small>包含系统配置和设置数据</small>
</label>
</div>
</div>
<div class="modal-footer">
<button class="action-btn secondary" @click="showCreateModal = false">{{ locale.cancel }}</button>
<button class="action-btn secondary" @click="showCreateModal = false">取消</button>
<button :disabled="createLoading" class="action-btn primary" @click="createBackup">
<span v-if="createLoading">{{ locale.creating }}</span>
<span v-else>{{ locale.createBackup }}</span>
<span v-if="createLoading">创建中...</span>
<span v-else>创建备份</span>
</button>
</div>
</div>
</div>
<!-- 导入备份模态框 -->
<div v-if="showUploadModal" class="backupmanager-modal-overlay" @click="showUploadModal = false">
<div v-if="showUploadModal" class="modal-overlay" @click="showUploadModal = false">
<div class="modal" @click.stop>
<div class="modal-header">
<h3>{{ locale.importFileTitle }}</h3>
<h3>导入备份文件</h3>
<button class="close-btn" @click="showUploadModal = false">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
@@ -126,8 +126,8 @@
<polyline points="17,8 12,3 7,8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
<h4>{{ locale.uploadTitle }}</h4>
<p>{{ locale.uploadDesc }}</p>
<h4>选择或拖拽备份文件</h4>
<p>支持 .json 格式的备份文件,最大 100MB</p>
<input
ref="fileInput"
accept=".json,application/json"
@@ -161,17 +161,17 @@
</div>
<div class="form-group">
<label>{{ locale.restoreMode }}</label>
<label>恢复模式</label>
<div class="radio-group">
<label class="radio-option">
<input v-model="restoreForm.mode" type="radio" value="merge" >
<span>{{ locale.mergeMode }}</span>
<small>{{ locale.mergeModeDesc }}</small>
<span>合并模式(推荐)</span>
<small>更新现有记录,添加新记录</small>
</label>
<label class="radio-option">
<input v-model="restoreForm.mode" type="radio" value="replace" >
<span>{{ locale.replaceMode }}</span>
<small>{{ locale.replaceModeDesc }}</small>
<span>替换模式</span>
<small>先清空数据,然后导入备份</small>
</label>
</div>
</div>
@@ -179,8 +179,8 @@
<div v-if="restoreForm.mode === 'replace'" class="form-group">
<label class="checkbox-option danger">
<input v-model="restoreForm.clearExisting" type="checkbox" >
<span>{{ locale.confirmClear }}</span>
<small>{{ locale.confirmClearDesc }}</small>
<span>我确认要清空现有数据</span>
<small>此操作不可逆,请谨慎操作</small>
</label>
</div>
@@ -190,8 +190,8 @@
>
<label class="checkbox-option">
<input v-model="restoreForm.overwriteSuperAdmin" type="checkbox" >
<span>{{ locale.overwriteSuperAdmin }}</span>
<small>{{ locale.overwriteSuperAdminDesc }}</small>
<span>覆盖备份中的超级管理员账号数据</span>
<small>关闭时将保留当前超级管理员及其第三方绑定、2FA相关数据</small>
</label>
</div>
@@ -204,13 +204,13 @@
<line x1="12" x2="12.01" y1="17" y2="17" />
</svg>
<div>
<h4>{{ locale.warningTitle }}</h4>
<p>{{ locale.warningDesc }}</p>
<h4>注意</h4>
<p>导入备份将会影响现有数据,请确保您了解操作的后果。</p>
</div>
</div>
</div>
<div class="modal-footer">
<button class="action-btn secondary" @click="showUploadModal = false">{{ locale.cancel }}</button>
<button class="action-btn secondary" @click="showUploadModal = false">取消</button>
<button
:disabled="
!selectedFile ||
@@ -220,8 +220,8 @@
class="action-btn primary"
@click="uploadFile"
>
<span v-if="uploadLoading">{{ locale.importing }}</span>
<span v-else>{{ locale.startImport }}</span>
<span v-if="uploadLoading">导入中...</span>
<span v-else>开始导入</span>
</button>
</div>
</div>
@@ -230,13 +230,8 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useLocale } from '~/utils/locale'
const { admin } = useLocale()
const locale = computed(() => admin.value?.backupManager || {})
const { msg: getLocaleText } = useLocaleText(locale)
// 响应式数据
const createLoading = ref(false)
@@ -310,14 +305,14 @@ const createBackup = async () => {
// 显示成功消息
const sizeText = backup.size ? ` (${formatFileSize(backup.size)})` : ''
if (window.$showNotification) {
window.$showNotification(getLocaleText('downloaded', backup.filename, sizeText), 'success')
window.$showNotification(`备份文件已下载: ${backup.filename}${sizeText}`, 'success')
}
console.log('✅ 文件下载成功:', backup.filename)
} catch (downloadError) {
console.error('下载失败:', downloadError)
if (window.$showNotification) {
window.$showNotification(getLocaleText('fileDownloadFailed', downloadError.message), 'error')
window.$showNotification('文件下载失败: ' + downloadError.message, 'error')
}
}
} else if (backup.downloadMode === 'file' && backup.filename) {
@@ -353,20 +348,20 @@ const createBackup = async () => {
// 显示成功消息
const sizeText = backup.size ? ` (${formatFileSize(backup.size)})` : ''
if (window.$showNotification) {
window.$showNotification(getLocaleText('downloaded', backup.filename, sizeText), 'success')
window.$showNotification(`备份文件已下载: ${backup.filename}${sizeText}`, 'success')
}
console.log('✅ 文件下载成功:', backup.filename)
} catch (downloadError) {
console.error('文件下载失败:', downloadError)
if (window.$showNotification) {
window.$showNotification(getLocaleText('fileDownloadFailed', downloadError.message), 'error')
window.$showNotification('文件下载失败: ' + downloadError.message, 'error')
}
}
} else {
console.error('无效的下载模式或缺少数据')
if (window.$showNotification) {
window.$showNotification(locale.value?.invalidResponse || '服务器返回了无效响应', 'error')
window.$showNotification('备份创建失败:无效的响应格式', 'error')
}
showCreateModal.value = false
return
@@ -374,16 +369,16 @@ const createBackup = async () => {
} else {
console.error('服务器响应格式错误:', response)
if (window.$showNotification) {
window.$showNotification(locale.value?.serverResponseInvalid || '服务器响应格式无效', 'error')
window.$showNotification('备份创建失败:服务器响应格式错误', 'error')
}
}
showCreateModal.value = false
} catch (error) {
console.error('创建备份失败:', error)
const errorMessage = error.data?.message || error.message || locale.value?.unknownError || '未知错误'
const errorMessage = error.data?.message || error.message || '未知错误'
if (window.$showNotification) {
window.$showNotification(getLocaleText('createFailed', errorMessage), 'error')
window.$showNotification('创建备份失败: ' + errorMessage, 'error')
}
} finally {
createLoading.value = false
@@ -404,7 +399,7 @@ const parseBackupSuperAdmin = async (file) => {
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
if (window.$showNotification) {
window.$showNotification(locale.value?.parseFailed || '备份文件解析失败', 'error')
window.$showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
}
console.error('解析备份文件失败:', error)
}
@@ -463,7 +458,7 @@ const uploadFile = async () => {
// 显示成功通知
if (window.$showNotification) {
window.$showNotification(
getLocaleText('restoreSuccess', response.details?.tablesProcessed || 0, response.details?.recordsRestored || 0),
`备份恢复成功!处理了 ${response.details?.tablesProcessed || 0} 个表,恢复了 ${response.details?.recordsRestored || 0} 条记录`,
'success'
)
@@ -471,7 +466,7 @@ const uploadFile = async () => {
if (response.details?.errors && response.details.errors.length > 0) {
setTimeout(() => {
window.$showNotification(
getLocaleText('restoreWarning', response.details.errors.length),
`恢复过程中发生了 ${response.details.errors.length} 个错误`,
'warning'
)
}, 1000)
@@ -479,7 +474,7 @@ const uploadFile = async () => {
// 显示即将重定向的通知
setTimeout(() => {
window.$showNotification(locale.value?.restoreCompleteRedirect || '恢复完成,即将刷新页面', 'info')
window.$showNotification('数据库恢复完成,3秒后将返回首页重新登录', 'info')
}, 2000)
}
@@ -498,13 +493,13 @@ const uploadFile = async () => {
}, 5000)
} else {
if (window.$showNotification) {
window.$showNotification(getLocaleText('importFailed', response.message || locale.value.unknownError), 'error')
window.$showNotification('备份导入失败: ' + (response.message || '未知错误'), 'error')
}
}
} catch (error) {
console.error('导入备份失败:', error)
if (window.$showNotification) {
window.$showNotification(getLocaleText('importFailed', error.data?.message || error.message), 'error')
window.$showNotification('导入备份失败: ' + (error.data?.message || error.message), 'error')
}
} finally {
uploadLoading.value = false
@@ -525,8 +520,8 @@ const formatFileSize = (bytes) => {
/* 主容器 */
.backup-manager {
min-height: 100vh;
background: var(--panel-bg-deep);
color: var(--panel-border-light);
background: var(--bg-primary, #1a1a1a);
color: var(--text-primary, #e5e5e5);
padding: 2rem;
position: relative;
}
@@ -540,13 +535,13 @@ const formatFileSize = (bytes) => {
.header h3 {
font-size: 2.5rem;
font-weight: 700;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin: 0 0 1rem 0;
}
.header .description {
font-size: 1.125rem;
color: var(--text-muted-dark);
color: var(--text-secondary, #a0a0a0);
margin: 0;
}
@@ -561,8 +556,8 @@ const formatFileSize = (bytes) => {
/* 操作卡片 */
.action-card {
background: var(--panel-border);
border: 1px solid var(--panel-border-dark);
background: var(--bg-tertiary, #2a2a2a);
border: 1px solid var(--border-secondary, #404040);
border-radius: 12px;
padding: 2rem;
text-align: center;
@@ -570,8 +565,8 @@ const formatFileSize = (bytes) => {
}
.action-card:hover {
border-color: var(--panel-border-dark);
background: var(--panel-border-subtle);
border-color: #606060;
background: var(--bg-tertiary, #2f2f2f);
}
.action-card .card-content {
@@ -587,12 +582,12 @@ const formatFileSize = (bytes) => {
width: 4rem;
height: 4rem;
margin: 0 auto 1.5rem;
background: var(--panel-border-dark);
background: #404040;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
}
.action-icon svg {
@@ -604,12 +599,12 @@ const formatFileSize = (bytes) => {
.action-card h4 {
font-size: 1.5rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin: 0 0 0.75rem 0;
}
.action-card p {
color: var(--text-muted-dark);
color: #a0a0a0;
font-size: 1rem;
margin: 0 0 2rem 0;
line-height: 1.6;
@@ -634,24 +629,24 @@ const formatFileSize = (bytes) => {
}
.action-btn.primary {
background: var(--color-accent);
background: #4a90e2;
}
.action-btn.primary:hover {
background: var(--color-accent);
background: #357abd;
}
.action-btn.secondary {
background: var(--panel-border-dark);
background: #606060;
}
.action-btn.secondary:hover {
background: var(--panel-border-dark);
background: #505050;
}
.action-btn:disabled {
background: var(--panel-border-dark) !important;
color: var(--text-muted) !important;
background: #404040 !important;
color: #808080 !important;
cursor: not-allowed;
opacity: 0.6;
}
@@ -662,13 +657,13 @@ const formatFileSize = (bytes) => {
}
/* 模态框样式 */
.backupmanager-modal-overlay {
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: var(--modal-overlay);
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
@@ -677,8 +672,8 @@ const formatFileSize = (bytes) => {
}
.modal {
background: var(--panel-border);
border: 1px solid var(--panel-border-dark);
background: var(--bg-tertiary, #2a2a2a);
border: 1px solid var(--border-secondary, #404040);
border-radius: 12px;
max-width: 600px;
width: 100%;
@@ -696,19 +691,19 @@ const formatFileSize = (bytes) => {
.current-status h4 {
margin: 0 0 15px 0;
color: var(--color-accent);
color: #4a90e2;
font-size: 18px;
}
.progress-stats p {
margin: 5px 0;
color: var(--text-muted-dark);
color: #a0a0a0;
}
.progress-bar {
width: 100%;
height: 8px;
background: var(--panel-border-dark);
background: #404040;
border-radius: 4px;
margin: 15px 0;
overflow: hidden;
@@ -716,7 +711,7 @@ const formatFileSize = (bytes) => {
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--color-accent), var(--color-accent));
background: linear-gradient(90deg, #4a90e2, #357abd);
border-radius: 4px;
transition: width 0.3s ease;
}
@@ -729,7 +724,7 @@ const formatFileSize = (bytes) => {
.progress-details h5,
.progress-errors h5 {
margin: 0 0 10px 0;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
font-size: 14px;
font-weight: 600;
}
@@ -738,17 +733,17 @@ const formatFileSize = (bytes) => {
.errors-list {
max-height: 150px;
overflow-y: auto;
border: 1px solid var(--panel-border-dark);
border: 1px solid #505050;
border-radius: 6px;
padding: 10px;
background: var(--text-primary);
background: var(--bg-quaternary, #333333);
}
.detail-item {
padding: 4px 0;
color: var(--text-muted-dark);
color: #a0a0a0;
font-size: 13px;
border-bottom: 1px solid var(--panel-border-dark);
border-bottom: 1px solid var(--border-secondary, #404040);
}
.detail-item:last-child {
@@ -757,9 +752,9 @@ const formatFileSize = (bytes) => {
.error-item {
padding: 4px 0;
color: var(--color-error-light);
color: #ff6b6b;
font-size: 13px;
border-bottom: 1px solid var(--panel-bg-darker);
border-bottom: 1px solid var(--border-secondary, #4a2a2a);
}
.error-item:last-child {
@@ -777,14 +772,14 @@ const formatFileSize = (bytes) => {
.modal-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin: 0;
}
.close-btn {
background: var(--panel-border-dark);
background: #404040;
border: none;
color: var(--text-muted-dark);
color: #a0a0a0;
cursor: pointer;
padding: 0.75rem;
border-radius: 8px;
@@ -792,8 +787,8 @@ const formatFileSize = (bytes) => {
}
.close-btn:hover {
background: var(--panel-border-dark);
color: var(--text-primary);
background: #505050;
color: var(--text-primary, #ffffff);
}
.close-btn svg {
@@ -807,11 +802,11 @@ const formatFileSize = (bytes) => {
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid var(--panel-border-dark);
border-top: 1px solid var(--border-secondary, #404040);
display: flex;
gap: 1rem;
justify-content: flex-end;
background: var(--panel-border-subtle);
background: var(--bg-tertiary, #252525);
}
.modal-footer button {
@@ -827,7 +822,7 @@ const formatFileSize = (bytes) => {
display: block;
font-size: 1.125rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin-bottom: 1rem;
}
@@ -843,8 +838,8 @@ const formatFileSize = (bytes) => {
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
background: var(--text-primary);
border: 1px solid var(--panel-border-dark);
background: var(--bg-quaternary, #333333);
border: 1px solid #505050;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
@@ -852,8 +847,8 @@ const formatFileSize = (bytes) => {
.radio-option:hover,
.checkbox-option:hover {
border-color: var(--panel-border-dark);
background: var(--panel-border-dark);
border-color: #606060;
background: #383838;
}
.radio-option input[type='radio'],
@@ -861,7 +856,7 @@ const formatFileSize = (bytes) => {
width: 1.25rem;
height: 1.25rem;
margin: 0;
accent-color: var(--color-accent);
accent-color: #4a90e2;
cursor: pointer;
}
@@ -875,7 +870,7 @@ const formatFileSize = (bytes) => {
display: block;
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin-bottom: 0.25rem;
}
@@ -883,21 +878,21 @@ const formatFileSize = (bytes) => {
.checkbox-option small {
display: block;
font-size: 0.875rem;
color: var(--text-muted-dark);
color: #a0a0a0;
line-height: 1.4;
}
.checkbox-option.danger {
border-color: var(--color-error-hover);
background: var(--backup-manager-danger-bg);
border-color: #d32f2f;
background: rgba(211, 47, 47, 0.1);
}
.checkbox-option.danger span {
color: var(--color-error-light);
color: #ff6b6b;
}
.checkbox-option.danger small {
color: var(--color-error-light);
color: #ff8a80;
}
/* 文件上传区域 */
@@ -906,37 +901,37 @@ const formatFileSize = (bytes) => {
}
.upload-area {
border: 2px dashed var(--panel-border-dark);
border: 2px dashed #505050;
border-radius: 8px;
padding: 3rem 2rem;
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: var(--text-primary);
background: var(--bg-quaternary, #333333);
}
.upload-area:hover,
.upload-area.drag-over {
border-color: var(--color-accent);
background: var(--panel-border-dark);
border-color: var(--border-secondary, #4a90e2);
background: #383838;
}
.upload-area svg {
width: 4rem;
height: 4rem;
color: var(--color-accent);
color: #4a90e2;
margin-bottom: 1.5rem;
}
.upload-area h4 {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin: 0 0 0.5rem 0;
}
.upload-area p {
color: var(--text-muted-dark);
color: #a0a0a0;
font-size: 1rem;
margin: 0;
}
@@ -946,8 +941,8 @@ const formatFileSize = (bytes) => {
align-items: center;
justify-content: space-between;
padding: 1.25rem;
background: var(--text-primary);
border: 1px solid var(--panel-border-dark);
background: var(--bg-quaternary, #333333);
border: 1px solid #505050;
border-radius: 8px;
margin-top: 1rem;
}
@@ -962,7 +957,7 @@ const formatFileSize = (bytes) => {
.file-info svg {
width: 1.5rem;
height: 1.5rem;
color: var(--color-accent);
color: #4a90e2;
flex-shrink: 0;
}
@@ -974,28 +969,28 @@ const formatFileSize = (bytes) => {
display: block;
font-size: 1rem;
font-weight: 500;
color: var(--text-primary);
color: var(--text-primary, #ffffff);
margin-bottom: 0.25rem;
}
.file-size {
display: block;
font-size: 0.875rem;
color: var(--text-muted-dark);
color: #a0a0a0;
}
.remove-file-btn {
background: var(--backup-manager-file-remove-bg);
background: rgba(211, 47, 47, 0.2);
border: none;
padding: 0.75rem;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
color: var(--color-error-light);
color: #ff6b6b;
}
.remove-file-btn:hover {
background: var(--backup-manager-file-remove-bg-hover);
background: rgba(211, 47, 47, 0.3);
}
.remove-file-btn svg {
@@ -1005,8 +1000,8 @@ const formatFileSize = (bytes) => {
/* 警告框 */
.warning-box {
background: var(--backup-manager-warning-box-bg);
border: 1px solid var(--color-orange);
background: rgba(255, 152, 0, 0.1);
border: 1px solid #ff9800;
border-radius: 8px;
padding: 1.5rem;
display: flex;
@@ -1018,7 +1013,7 @@ const formatFileSize = (bytes) => {
.warning-box svg {
width: 1.5rem;
height: 1.5rem;
color: var(--color-orange);
color: #ff9800;
flex-shrink: 0;
margin-top: 0.125rem;
}
@@ -1028,14 +1023,14 @@ const formatFileSize = (bytes) => {
}
.warning-box h4 {
color: var(--color-orange);
color: #ff9800;
font-weight: 600;
margin: 0 0 0.5rem 0;
font-size: 1rem;
}
.warning-box p {
color: var(--color-orange);
color: #ffb74d;
margin: 0;
font-size: 0.875rem;
line-height: 1.5;
@@ -1050,7 +1045,7 @@ const formatFileSize = (bytes) => {
border-radius: 8px;
color: white;
font-weight: 500;
box-shadow: 0 4px 12px var(--mask-30);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1001;
animation: slideInRight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
@@ -1061,23 +1056,23 @@ const formatFileSize = (bytes) => {
}
.notification.success {
background: var(--color-success);
border: 1px solid var(--color-success);
background: var(--bg-tertiary, #2e7d32);
border: 1px solid var(--border-secondary, #4caf50);
}
.notification.error {
background: var(--color-error-hover);
border: 1px solid var(--color-error);
background: #d32f2f;
border: 1px solid #f44336;
}
.notification.warning {
background: var(--color-orange);
border: 1px solid var(--color-orange);
background: #f57c00;
border: 1px solid #ff9800;
}
.notification.info {
background: var(--color-accent-hover);
border: 1px solid var(--color-accent);
background: var(--bg-secondary, #1976d2);
border: 1px solid var(--border-secondary, #2196f3);
}
@keyframes slideInRight {
File diff suppressed because it is too large Load Diff
+185 -207
View File
@@ -5,104 +5,104 @@
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight flex items-center gap-3">
{{ locale.title }}
<h2 class="text-2xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
歌曲黑名单管理
</h2>
<p class="text-xs text-text-tertiary mt-1 font-medium">
{{ locale.desc }}
<p class="text-xs text-zinc-500 mt-1 font-medium">
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
</p>
</div>
</div>
<!-- 添加黑名单项表单 -->
<section class="bg-bg-secondary-40 border border-border-secondary rounded-xl p-8 shadow-xl">
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] mb-6 px-1">
{{ locale.addTitle }}
<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-text-disabled uppercase tracking-widest px-1"
>{{ locale.type }}</label
<div class="xl:col-span-3 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁类型</label
>
<div class="grid grid-cols-2 gap-1 p-1 bg-bg-primary border border-border-secondary rounded-lg">
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg gap-1">
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'SONG' ? 'bg-bg-tertiary text-primary' : 'text-text-disabled'"
@click="selectType('SONG')"
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" /> {{ locale.song }}
<Music :size="12" /> 歌曲
</button>
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'KEYWORD' ? 'bg-bg-tertiary text-info' : 'text-text-disabled'"
@click="selectType('KEYWORD')"
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" /> {{ locale.keyword }}
<Type :size="12" /> 关键词
</button>
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'LANGUAGE' ? 'bg-bg-tertiary text-warning' : 'text-text-disabled'"
@click="selectType('LANGUAGE')"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'LANGUAGE' ? 'bg-zinc-800 text-amber-400' : 'text-zinc-600'"
@click="newItem.type = 'LANGUAGE'"
>
<Languages :size="12" /> {{ locale.language }}
<Languages :size="12" /> 语种
</button>
<button
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'GENRE' ? 'bg-bg-tertiary text-success' : 'text-text-disabled'"
@click="selectType('GENRE')"
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'GENRE' ? 'bg-zinc-800 text-emerald-400' : 'text-zinc-600'"
@click="newItem.type = 'GENRE'"
>
<AudioLines :size="12" /> {{ locale.genre }}
<Disc3 :size="12" /> 曲风
</button>
</div>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
{{ valueLabel }}
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
{{ typeValueLabel }}
</label>
<input
v-if="newItem.type === 'SONG' || newItem.type === 'KEYWORD'"
v-model="newItem.value"
type="text"
:placeholder="
newItem.type === 'SONG' ? locale.songPlaceholder : locale.keywordPlaceholder
"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
:placeholder="typePlaceholder"
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"
>
<CustomSelect
v-else
v-model="newItem.value"
:options="typeValueOptions"
/>
<p
v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'"
class="text-[10px] text-text-secondary px-1 leading-relaxed"
>
{{ newItem.type === 'LANGUAGE' ? locale.languageHint : locale.genreHint }}
</div>
<div v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'" class="xl:col-span-5 xl:col-start-auto space-y-1">
<p class="text-[10px] text-zinc-600 font-bold leading-relaxed px-1">
<template v-if="newItem.type === 'LANGUAGE'">
💡 支持多语种匹配:中文 / 英文 / 日文 / 韩文 / 俄文 / 阿拉伯文 等,支持逗号分隔,例如:<span class="text-amber-400">日文,韩文</span>
</template>
<template v-else>
💡 支持曲风关键词,可逗号分隔,例如:<span class="text-emerald-400">DJ,摇滚,rap</span>
</template>
</p>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.reason }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁原因 (可选)</label
>
<input
v-model="newItem.reason"
type="text"
:placeholder="locale.reasonPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
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-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
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" />
<AppSpinner :size="16" />
{{ loading ? locale.processing : locale.addButton }}
<div
v-else
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
{{ loading ? '处理中...' : '添加到黑名单' }}
</button>
</div>
</div>
@@ -110,30 +110,28 @@
<!-- 筛选和搜索 -->
<div
class="bg-bg-secondary-20 border border-border-secondary rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
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-text-secondary" :size="18" />
<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="locale.searchPlaceholder"
class="w-full bg-bg-primary-50 border border-border-secondary rounded-lg pl-12 pr-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
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="locale.typeFilter"
v-model="filters.type"
:options="typeOptions"
label-key="label"
value-key="value"
label="类型筛选"
:value="typeFilterLabel"
:options="['全部类型', '具体歌曲', '关键词', '语种', '曲风']"
class="flex-1 md:w-48"
@change="() => { pagination.page = 1; loadBlacklist(); }"
@change="handleTypeFilterChange"
/>
<button
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-disabled hover:text-primary transition-all active:scale-95 flex items-center justify-center"
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" />
@@ -144,8 +142,8 @@
<!-- 黑名单列表 -->
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h4 class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
{{ locale.listTitle(pagination.total) }}
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
黑名单列表 ({{ pagination.total }} 项)
</h4>
</div>
@@ -161,64 +159,67 @@
<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-bg-secondary-30 border rounded-xl transition-all hover:border-border-tertiary"
:class="[item.isActive ? 'border-border-secondary' : 'border-border-secondary-40 opacity-60']"
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="getTypeMeta(item.type).boxClass"
:class="getTypeIconClass(item.type)"
>
<component :is="getTypeMeta(item.type).icon" :size="22" />
<Music v-if="item.type === 'SONG'" :size="22" />
<Type v-else-if="item.type === 'KEYWORD'" :size="22" />
<Languages v-else-if="item.type === 'LANGUAGE'" :size="22" />
<Disc3 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="getTypeMeta(item.type).badgeClass"
:class="getTypeBadgeClass(item.type)"
>
{{ getTypeMeta(item.type).label }}
{{ getTypeLabel(item.type) }}
</span>
<h5 class="text-base font-black text-text-primary truncate tracking-tight">
<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-text-tertiary font-bold text-[11px]">
<MessageSquare :size="12" class="text-text-secondary" />
{{ locale.reasonPrefix }} {{ item.reason || locale.noReason }}
<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-text-disabled font-bold text-[10px] uppercase tracking-tighter"
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
>
<Clock :size="11" class="text-text-primary" />
{{ locale.createdAt }} {{ formatDate(item.createdAt) }}
<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-border-secondary-50"
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-bg-tertiary text-text-tertiary hover:text-warning hover:bg-warning-5 hover:border-warning-10 border border-transparent'
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
? '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 ? locale.disable : locale.enable }}
{{ item.isActive ? '禁用' : '启用' }}
</button>
<button
:disabled="loading"
class="p-3 bg-bg-primary border border-border-secondary rounded-xl text-text-secondary hover:text-error hover:border-error-30 transition-all active:scale-95"
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" />
@@ -232,24 +233,26 @@
v-if="loading && blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center space-y-4"
>
<AppSpinner :size="48" />
<p class="text-text-tertiary text-xs font-bold uppercase tracking-widest">{{ locale.loading }}</p>
<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-bg-secondary-10 border border-border-secondary-40 border-dashed rounded-xl"
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-bg-primary border border-border-secondary flex items-center justify-center text-text-primary shadow-xl"
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-text-disabled">{{ locale.emptyTitle }}</h6>
<p class="text-[10px] text-text-secondary font-bold uppercase tracking-widest">
{{ locale.emptyDesc }}
<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>
@@ -259,19 +262,19 @@
<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-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95 flex items-center justify-center"
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-bg-secondary-50 border border-border-secondary rounded-lg text-[11px] font-black text-text-tertiary uppercase tracking-widest"
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"
>
{{ locale.pagination(pagination.page, pagination.pages) }}
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
</div>
<button
:disabled="pagination.page >= pagination.pages || loading"
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95 flex items-center justify-center"
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" />
@@ -290,37 +293,39 @@
>
<div
v-if="showDeleteDialog"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
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-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
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-error-10 text-error flex items-center justify-center border border-error-20 shadow-2xl shadow-[var(--error-glow-10)]"
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-text-primary">{{ locale.removeTitle }}</h4>
<p class="text-xs text-text-tertiary leading-relaxed">
{{ locale.removeMessage(deleteTargetItem?.value || '') }}
<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-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
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"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="loading"
class="flex-1 px-4 py-4 bg-error hover:bg-error text-text-primary text-[10px] font-black rounded-lg shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
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 ? locale.deleting : locale.confirmRemove }}
{{ loading ? '删除中...' : '确认移除' }}
</button>
</div>
</div>
@@ -330,15 +335,12 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import {
ShieldAlert,
Music,
Type,
Languages,
AudioLines,
Plus,
Search,
Filter,
@@ -349,30 +351,14 @@ import {
Ban,
ChevronLeft,
ChevronRight,
ChevronDown
ChevronDown,
Languages,
Disc3
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import {
BLACKLIST_LANGUAGE_VALUES,
BLACKLIST_GENRE_VALUES,
BLACKLIST_LANGUAGE_LABEL_EN,
BLACKLIST_GENRE_LABEL_EN
} from '~/utils/blacklist'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const { admin, currentLocale } = useLocale()
const locale = computed(() => {
const base = admin.value?.blacklist || {}
return {
...base,
listTitle: (total) => formatLocaleValue(base.listTitle, total),
pagination: (page, pages) => formatLocaleValue(base.pagination, page, pages),
removeMessage: (value) => formatLocaleValue(base.removeMessage, value),
statusSuccess: (statusText) => formatLocaleValue(base.statusSuccess, statusText)
}
})
const blacklist = ref([])
const loading = ref(false)
@@ -401,80 +387,66 @@ const pagination = reactive({
pages: 0
})
const typeOptions = computed(() => [
{ label: locale.value?.allTypes || 'All Types', value: '' },
{ label: locale.value?.song || 'Song', value: 'SONG' },
{ label: locale.value?.keyword || 'Keyword', value: 'KEYWORD' },
{ label: locale.value?.language || 'Language', value: 'LANGUAGE' },
{ label: locale.value?.genre || 'Genre', value: 'GENRE' }
])
const typeFilterLabel = computed(() => {
if (filters.type === 'SONG') return '具体歌曲'
if (filters.type === 'KEYWORD') return '关键词'
if (filters.type === 'LANGUAGE') return '语种'
if (filters.type === 'GENRE') return '曲风'
return '全部类型'
})
// 类型 → 图标/徽章/文案元数据
const getTypeMeta = (type) => {
const typeValueLabel = computed(() => {
if (newItem.type === 'SONG') return '歌曲名称 - 艺术家'
if (newItem.type === 'KEYWORD') return '关键词内容'
if (newItem.type === 'LANGUAGE') return '语种名称'
if (newItem.type === 'GENRE') return '曲风名称'
return '内容'
})
const typePlaceholder = computed(() => {
if (newItem.type === 'SONG') return '例如: 歌曲名 - 艺术家'
if (newItem.type === 'KEYWORD') return '例如: 敏感词、特定风格'
if (newItem.type === 'LANGUAGE') return '例如: 日文 或 中文,英文,日文'
if (newItem.type === 'GENRE') return '例如: DJ 或 流行,摇滚,rap'
return '请输入内容'
})
const getTypeLabel = (type: string) => {
if (type === 'SONG') return '具体歌曲'
if (type === 'KEYWORD') return '关键词'
if (type === 'LANGUAGE') return '语种'
if (type === 'GENRE') return '曲风'
return type
}
const getTypeIconClass = (type: string) => {
switch (type) {
case 'SONG':
return {
icon: Music,
label: locale.value?.song || 'Song',
boxClass: 'bg-primary-hover-10 text-primary border-primary-20 shadow-lg shadow-[var(--primary-glow-5)]',
badgeClass: 'bg-primary-hover-10 text-primary border-primary-20'
}
return 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
case 'KEYWORD':
return 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
case 'LANGUAGE':
return {
icon: Languages,
label: locale.value?.language || 'Language',
boxClass: 'bg-warning-10 text-warning border-warning-20 shadow-lg shadow-[var(--warning-glow-5)]',
badgeClass: 'bg-warning-10 text-warning border-warning-20'
}
return 'bg-amber-600/10 text-amber-500 border-amber-500/20 shadow-lg shadow-amber-900/5'
case 'GENRE':
return {
icon: AudioLines,
label: locale.value?.genre || 'Genre',
boxClass: 'bg-success-10 text-success border-success-20 shadow-lg shadow-[var(--success-glow-5)]',
badgeClass: 'bg-success-10 text-success border-success-20'
}
return 'bg-emerald-600/10 text-emerald-500 border-emerald-500/20 shadow-lg shadow-emerald-900/5'
default:
return {
icon: Type,
label: locale.value?.keyword || 'Keyword',
boxClass: 'bg-info-10 text-info border-info-20 shadow-lg shadow-[var(--info-glow-5)]',
badgeClass: 'bg-info-10 text-info border-info-20'
}
return 'bg-zinc-600/10 text-zinc-500 border-zinc-500/20'
}
}
// 语种/曲风候选值选项(存储值为规范中文标签,英文界面附注显示名)
const localeValueLabel = (value, enMap) =>
currentLocale.value === 'en-US' ? `${enMap[value] || value} (${value})` : value
const typeValueOptions = computed(() => {
if (newItem.type === 'LANGUAGE') {
return BLACKLIST_LANGUAGE_VALUES.map((value) => ({
value,
label: localeValueLabel(value, BLACKLIST_LANGUAGE_LABEL_EN)
}))
const getTypeBadgeClass = (type: string) => {
switch (type) {
case 'SONG':
return 'bg-blue-600/10 text-blue-400 border-blue-500/20'
case 'KEYWORD':
return 'bg-purple-600/10 text-purple-400 border-purple-500/20'
case 'LANGUAGE':
return 'bg-amber-600/10 text-amber-400 border-amber-500/20'
case 'GENRE':
return 'bg-emerald-600/10 text-emerald-400 border-emerald-500/20'
default:
return 'bg-zinc-600/10 text-zinc-400 border-zinc-500/20'
}
if (newItem.type === 'GENRE') {
return BLACKLIST_GENRE_VALUES.map((value) => ({
value,
label: localeValueLabel(value, BLACKLIST_GENRE_LABEL_EN)
}))
}
return []
})
const valueLabel = computed(() => {
if (newItem.type === 'SONG') return locale.value?.songLabel
if (newItem.type === 'KEYWORD') return locale.value?.keywordLabel
if (newItem.type === 'LANGUAGE') return locale.value?.languageLabel
return locale.value?.genreLabel
})
// 切换类型时清空已选值
const selectType = (type) => {
if (newItem.type === type) return
newItem.type = type
newItem.value = ''
}
let searchTimeout = null
@@ -499,16 +471,24 @@ const loadBlacklist = async () => {
blacklist.value = response.blacklist
Object.assign(pagination, response.pagination)
} catch (err) {
const fetchFailed = locale.value?.fetchFailed || 'Failed to load blacklist'
error.value = fetchFailed
error.value = '获取黑名单失败'
console.error('获取黑名单失败:', err)
showNotification(`${fetchFailed}: ${getErrorMessage(err)}`, 'error')
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 if (label === '语种') filters.type = 'LANGUAGE'
else if (label === '曲风') filters.type = 'GENRE'
else filters.type = ''
pagination.page = 1
loadBlacklist()
}
// 添加黑名单项
const addBlacklistItem = async () => {
@@ -529,7 +509,7 @@ const addBlacklistItem = async () => {
...useAuth().getAuthConfig()
})
showNotification(locale.value.addSuccess, 'success')
showNotification('黑名单项添加成功', 'success')
// 重置表单
newItem.value = ''
@@ -539,9 +519,9 @@ const addBlacklistItem = async () => {
pagination.page = 1
await loadBlacklist()
} catch (err) {
error.value = getErrorMessage(err) || locale.value.addFailed
error.value = err.data?.message || '添加黑名单项失败'
console.error('添加黑名单项失败:', err)
showNotification(locale.value.addFailedPrefix + error.value, 'error')
showNotification('添加失败: ' + error.value, 'error')
} finally {
loading.value = false
}
@@ -562,11 +542,11 @@ const toggleItemStatus = async (item) => {
})
item.isActive = !item.isActive
showNotification(locale.value.statusSuccess(item.isActive ? locale.value.enable : locale.value.disable), 'success')
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
} catch (err) {
error.value = locale.value.updateFailed
error.value = '更新状态失败'
console.error('更新状态失败:', err)
showNotification(locale.value.updateFailed, 'error')
showNotification('更新状态失败', 'error')
} finally {
loading.value = false
}
@@ -591,12 +571,12 @@ const confirmDelete = async () => {
...useAuth().getAuthConfig()
})
showNotification(locale.value.deleteSuccess, 'success')
showNotification('黑名单项删除成功', 'success')
await loadBlacklist()
} catch (err) {
error.value = locale.value.deleteFailed
error.value = '删除失败'
console.error('删除失败:', err)
showNotification(`${locale.value.deleteFailed}: ${getErrorMessage(err)}`, 'error')
showNotification('删除失败: ' + err.message, 'error')
} finally {
loading.value = false
showDeleteDialog.value = false
@@ -622,9 +602,7 @@ const changePage = (page) => {
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
const date = new Date(dateString)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString(currentLocale.value, {
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
@@ -667,7 +645,7 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
</style>
+277
View File
@@ -0,0 +1,277 @@
<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
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"
:disabled="loading"
@click="load"
>
<RefreshCw :size="14" /> 刷新
</button>
<button
class="flex items-center gap-2 px-6 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"
@click="openCreate"
>
<Plus :size="14" /> 新建
</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="space-y-3">
<div
v-for="item in entries"
:key="item.id"
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest"
:class="item.published ? 'bg-emerald-500/10 text-emerald-300' : 'bg-zinc-800/50 text-zinc-400'"
>
{{ item.published ? '已发布' : '草稿' }}
</span>
<span class="px-2.5 py-1 rounded-lg bg-blue-500/10 text-blue-300 text-[10px] font-black">
v{{ item.version }}
</span>
<p class="text-sm font-black text-zinc-100 truncate">{{ item.title }}</p>
</div>
<p class="text-[10px] text-zinc-500 mt-2">
{{ formatDate(item.publishedAt || item.createdAt) }}
</p>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
@click="openEdit(item)"
>
<Pencil :size="14" class="inline-block mr-1" /> 编辑
</button>
<button
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
:disabled="savingId === item.id"
@click="togglePublish(item)"
>
<UploadCloud :size="14" class="inline-block mr-1" />
{{ item.published ? '取消发布' : '发布' }}
</button>
<button
class="px-3 py-2 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
:disabled="savingId === item.id"
@click="remove(item)"
>
<Trash2 :size="14" class="inline-block mr-1" /> 删除
</button>
</div>
</div>
<div v-if="entries.length === 0" class="py-20 text-center text-zinc-500">
暂无更新日志
</div>
</div>
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm p-4 flex items-end sm:items-center justify-center">
<div class="w-full max-w-2xl bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between">
<p class="text-sm font-black text-zinc-100">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
@click="closeModal"
>
<X :size="16" class="mx-auto" />
</button>
</div>
<div class="p-5 space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">版本号</label>
<input v-model="form.version" type="text" class="w-full bg-zinc-900 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="如:2.4.1" >
</div>
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">标题</label>
<input v-model="form.title" type="text" class="w-full bg-zinc-900 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="如:投稿体验优化" >
</div>
</div>
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">内容</label>
<textarea
v-model="form.content"
:rows="10"
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all font-mono"
placeholder="支持纯文本(建议用分点)。"
/>
</div>
<div class="flex items-center justify-between gap-4 p-3 rounded-xl border border-zinc-800 bg-zinc-900/40">
<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="form.published" type="checkbox" class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer" >
</div>
</div>
<div class="px-5 py-4 border-t border-zinc-800 flex items-center justify-end gap-3">
<button
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm font-bold"
@click="closeModal"
>
取消
</button>
<button
class="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-black disabled:opacity-50"
:disabled="savingModal"
@click="save"
>
{{ savingModal ? '保存中...' : '保存' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { Plus, RefreshCw, Pencil, UploadCloud, Trash2, X } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
const { showToast } = useToast()
const loading = ref(true)
const entries = ref([])
const savingId = ref(null)
const showModal = ref(false)
const savingModal = ref(false)
const editingId = ref(null)
const form = ref({ version: '', title: '', content: '', published: false })
const formatDate = (value) => {
if (!value) return ''
const d = new Date(value)
if (Number.isNaN(d.getTime())) return ''
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
}
const load = async () => {
try {
loading.value = true
const data = await $fetch('/api/admin/changelog', { credentials: 'include', query: { t: Date.now() } })
entries.value = Array.isArray(data) ? data : []
} catch (e) {
showToast('加载更新日志失败', 'error')
} finally {
loading.value = false
}
}
const openCreate = () => {
editingId.value = null
form.value = { version: '', title: '', content: '', published: false }
showModal.value = true
}
const openEdit = (item) => {
editingId.value = item.id
form.value = {
version: item.version || '',
title: item.title || '',
content: item.content || '',
published: !!item.published
}
showModal.value = true
}
const closeModal = () => {
showModal.value = false
}
const save = async () => {
const payload = {
version: (form.value.version || '').trim(),
title: (form.value.title || '').trim(),
content: (form.value.content || '').trim(),
published: !!form.value.published
}
if (!payload.version || !payload.title || !payload.content) {
showToast('请填写版本号、标题与内容', 'error')
return
}
try {
savingModal.value = true
if (editingId.value) {
await $fetch(`/api/admin/changelog/${editingId.value}`, {
method: 'PUT',
credentials: 'include',
body: payload
})
showToast('已保存', 'success')
} else {
await $fetch('/api/admin/changelog', {
method: 'POST',
credentials: 'include',
body: payload
})
showToast('已创建', 'success')
}
showModal.value = false
await load()
} catch (e) {
showToast('保存失败', 'error')
} finally {
savingModal.value = false
}
}
const togglePublish = async (item) => {
try {
savingId.value = item.id
await $fetch(`/api/admin/changelog/${item.id}`, {
method: 'PUT',
credentials: 'include',
body: { published: !item.published }
})
await load()
} catch {
showToast('更新发布状态失败', 'error')
} finally {
savingId.value = null
}
}
const remove = async (item) => {
try {
savingId.value = item.id
await $fetch(`/api/admin/changelog/${item.id}`, {
method: 'DELETE',
credentials: 'include'
})
await load()
showToast('已删除', 'success')
} catch {
showToast('删除失败', 'error')
} finally {
savingId.value = null
}
}
onMounted(load)
</script>
File diff suppressed because it is too large Load Diff
+216 -253
View File
@@ -2,9 +2,9 @@
<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-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary font-medium">
{{ locale.desc }}
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">数据库操作</h2>
<p class="text-xs text-zinc-500 font-medium">
执行系统底层维护任务,包括备份、恢复及全局数据重置
</p>
</div>
@@ -14,53 +14,49 @@
v-for="card in cards"
:key="card.id"
:class="[
'group relative bg-bg-secondary-40 border border-border-secondary rounded-2xl p-8 transition-all hover:border-border-tertiary hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
card.isDanger ? 'hover:border-error-20' : ''
'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-bg-primary border border-border-secondary transition-all flex items-center justify-center',
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
card.isDanger
? 'text-error border-error-10'
: card.colorClass === 'primary'
? 'text-primary border-primary-20'
: card.colorClass === 'warning'
? 'text-warning border-warning-20'
: 'text-success border-success-20'
? '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-error-10 text-error text-[9px] font-black uppercase tracking-widest border border-error-20 rounded"
>{{ locale.highRisk }}</span
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-text-primary group-hover:text-primary transition-colors">
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
{{ card.title }}
</h3>
<p class="text-xs text-text-tertiary leading-relaxed font-medium">
<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-bg-primary border border-border-secondary text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary hover:border-border-tertiary"
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-bg-primary border border-error-30 text-error hover:bg-error hover:text-text-primary hover:border-error shadow-lg shadow-[var(--error-glow-5)]'
? '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)">{{ locale.executing }}</span>
<span v-if="isLoading(card.id)">执行中...</span>
<span v-else>{{ card.btnText }}</span>
</button>
</div>
@@ -69,19 +65,19 @@
<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-error-5' : card.colorClass === 'primary' ? 'bg-primary-5' : card.colorClass === 'warning' ? 'bg-warning-5' : 'bg-success-5'
card.isDanger ? 'bg-rose-500/5' : `bg-${card.color}-500/5`
]"
/>
</div>
</div>
<!-- 维护建议 -->
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
<AlertCircle class="text-primary shrink-0 mt-0.5 w-[18px] h-[18px]" />
<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-text-secondary">{{ locale.maintenanceTitle }}</p>
<p class="text-[10px] text-text-tertiary leading-relaxed">
{{ locale.maintenanceDesc }}
<p class="text-[11px] font-bold text-zinc-300">数据库维护建议</p>
<p class="text-[10px] text-zinc-500 leading-relaxed">
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
</p>
</div>
</div>
@@ -91,14 +87,14 @@
v-if="activeModal === 'backup'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
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-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.backupTitle }}</h3>
<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-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
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" />
@@ -106,52 +102,52 @@
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
{{ locale.selectContent }}
<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-bg-primary-50 border border-border-secondary rounded-xl cursor-pointer hover:border-border-tertiary transition-all group"
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-border-secondary bg-bg-secondary"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
>
</div>
<div>
<p
class="text-xs font-bold text-text-primary group-hover:text-primary transition-colors"
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ item.label }}
</p>
<p class="text-[10px] text-text-disabled font-medium mt-0.5">{{ item.desc }}</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-primary-5 border border-primary-10 rounded-xl">
<p class="text-[10px] text-text-tertiary text-center italic">
{{ locale.backupHint }}
<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-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<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-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="createLoading"
class="px-8 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
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 ? locale.exporting : locale.startExport }}
{{ createLoading ? '正在导出...' : '开始导出' }}
</button>
</div>
</div>
@@ -162,14 +158,14 @@
v-if="activeModal === 'restore'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
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-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.restoreTitle }}</h3>
<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-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
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" />
@@ -177,19 +173,19 @@
</div>
<div class="p-8 space-y-6">
<div
class="border-2 border-dashed border-border-secondary rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-success-50 hover:bg-success-5 transition-all cursor-pointer"
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-text-secondary mb-4 group-hover:text-success transition-colors"
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
/>
<h5 class="text-sm font-bold text-text-secondary">
{{ selectedFile ? selectedFile.name : locale.selectFile }}
<h5 class="text-sm font-bold text-zinc-300">
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
</h5>
<p class="text-[10px] text-text-disabled font-bold uppercase mt-1 tracking-widest">
{{ locale.fileHint }}
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
仅支持 VoiceHub 导出的 .json 格式
</p>
<input
ref="fileInput"
@@ -201,54 +197,54 @@
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.restoreMode }}</label
<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-bg-primary border-success-30'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
? '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-success' : 'text-text-tertiary'
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
{{ locale.restoreModes?.merge?.title ?? '合并恢复' }}
增量模式
</h6>
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.merge?.desc ?? '保留现有数据并合并备份内容' }}</p>
<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-bg-primary border-success-30'
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
? '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-success' : 'text-text-tertiary'
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
{{ locale.restoreModes?.replace?.title ?? '覆盖恢复' }}
覆盖模式
</h6>
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.replace?.desc ?? '清除现有数据后恢复备份内容' }}</p>
<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-bg-primary border border-border-secondary rounded-xl"
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
>
<label class="flex items-start gap-3 cursor-pointer">
<input
@@ -257,36 +253,36 @@
class="mt-0.5 accent-emerald-500"
>
<div>
<p class="text-xs font-bold text-text-primary">{{ locale.overwriteSuperAdmin }}</p>
<p class="text-[10px] text-text-tertiary mt-1">
{{ locale.overwriteSuperAdminDesc }}
<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-warning-5 border border-warning-10 rounded-xl flex items-start gap-3"
class="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-warning shrink-0 mt-0.5 w-4 h-4" />
<p class="text-[10px] text-text-tertiary leading-normal font-medium">
{{ locale.replaceWarning }}
<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-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<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-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="uploadLoading || !selectedFile"
class="px-8 py-2 bg-success hover:bg-success text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
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 || locale.restoring : locale.confirmRestore }}
{{ uploadLoading ? restoreProgress || '正在恢复...' : '确认并开始恢复' }}
</button>
</div>
</div>
@@ -297,14 +293,14 @@
v-if="activeModal === 'reset-seq'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
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-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.resetSequenceTitle }}</h3>
<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-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
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" />
@@ -312,47 +308,47 @@
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.selectTargetTable }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择目标表</label
>
<CustomSelect
v-model="sequenceForm.table"
:options="locale.tableOptions || defaultTableOptions"
label-key="label"
value-key="value"
:options="tableOptions"
class="w-full"
/>
</div>
<div class="p-6 bg-bg-primary-50 border border-border-secondary rounded-2xl space-y-4">
<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-warning-10 text-warning flex items-center justify-center"
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-text-secondary uppercase tracking-widest">
{{ locale.sequenceHelpTitle }}
<h6 class="text-xs font-bold text-zinc-300 uppercase tracking-widest">
什么是重置序列?
</h6>
</div>
<p class="text-[11px] text-text-tertiary leading-relaxed font-medium">
{{ locale.sequenceHelpDesc }}
<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-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
<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-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="sequenceLoading || !sequenceForm.table"
class="px-8 py-2 bg-warning hover:bg-warning text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
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 ? locale.resetting : locale.executeReset }}
{{ sequenceLoading ? '正在重置...' : '执行重置' }}
</button>
</div>
</div>
@@ -363,14 +359,14 @@
v-if="activeModal === 'reset-db'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
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-border-secondary flex items-center justify-between">
<h3 class="text-xl font-black text-error tracking-tight">{{ locale.dangerResetTitle }}</h3>
<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-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
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" />
@@ -378,120 +374,74 @@
</div>
<div class="p-8 space-y-6">
<div
class="p-6 bg-error-10 border border-error-20 rounded-2xl flex flex-col items-center text-center"
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
>
<Trash2 class="text-error mb-4 w-12 h-12" />
<h4 class="text-lg font-black text-error tracking-tight">
{{ locale.dangerResetHeading }}
<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-text-tertiary mt-2 font-medium leading-relaxed">
{{ locale.dangerResetPrefix }}
<span class="text-text-secondary font-bold"
>{{ locale.dangerResetScope }}</span
<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-error-80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
class="text-[11px] font-black text-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
>
{{ locale.confirmCodeLabel }}
请输入以下代码以确认操作
</label>
<div
class="bg-bg-primary border border-error-30 rounded-xl px-4 py-3 font-mono text-[10px] text-error text-center select-all"
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="locale.confirmCodePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-error-40 text-center font-mono placeholder:text-text-secondary"
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-bg-secondary hover:bg-bg-tertiary text-text-tertiary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
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'"
>
{{ locale.cancel }}
取消
</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-bg-tertiary disabled:text-text-disabled disabled:cursor-not-allowed disabled:border-border-tertiary"
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-error hover:bg-error text-text-primary shadow-[var(--error-glow-20)] active:scale-95'
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
: ''
"
@click="resetDatabase"
>
{{ resetLoading ? locale.resetting : locale.confirmResetDatabase }}
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
</button>
</div>
</div>
</div>
</div>
<!-- 自动备份设置 -->
<BackupAutoSettings
:visible="activeModal === 'auto-backup'"
@close="activeModal = 'none'"
/>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, CloudUpload, X } from '@lucide/vue'
import { ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import BackupAutoSettings from '~/components/Admin/BackupAutoSettings.vue'
import { useToast } from '~/composables/useToast'
import { useAuth } from '~/composables/useAuth'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const auth = useAuth()
const { admin } = useLocale()
const locale = computed(() => admin.value?.databaseManager || {})
const defaultTableOptions = [
{ label: '重置所有表', value: 'all' },
{ label: '歌曲表', value: 'Song' },
{ label: '用户表', value: 'User' },
{ label: '用户身份表', value: 'UserIdentity' },
{ label: '用户状态日志表', value: 'UserStatusLog' },
{ label: '投票表', value: 'Vote' },
{ label: '排期表', value: 'Schedule' },
{ label: '通知表', value: 'Notification' },
{ label: '通知设置表', value: 'NotificationSettings' },
{ label: '播放时段表', value: 'PlayTime' },
{ label: '学期表', value: 'Semester' },
{ label: '系统设置表', value: 'SystemSettings' },
{ label: '歌曲黑名单表', value: 'SongBlacklist' },
{ label: '歌曲重播申请表', value: 'SongReplayRequest' },
{ label: '投稿时段表', value: 'RequestTime' },
{ label: '邮件模板表', value: 'EmailTemplate' }
]
const getMessage = (key) => locale.value?.messages?.[key] ?? `数据库操作:${key}`
const getLogMessage = (key) => locale.value?.logs?.[key] || key
const formatString = (value, args) => {
if (typeof value !== 'string') return value
return value.replace(/{(\d+)}/g, (match, index) =>
args[index] !== undefined ? String(args[index]) : match
)
}
const getErrorMessage = (key, ...args) => {
const message = locale.value?.errors?.[key]
if (typeof message === 'function') return message(...args)
return formatString(message, args) || args.find(Boolean) || key
}
const getProgressMessage = (key, ...args) => {
const message = locale.value?.progress?.[key]
if (typeof message === 'function') return message(...args)
return formatString(message, args) || `数据库操作进行中:${key}`
}
// 状态
const activeModal = ref('none')
@@ -506,68 +456,60 @@ const hasSuperAdminInBackup = ref(false)
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
// 卡片配置
const cards = computed(() => [
const cards = [
{
id: 'backup',
title: locale.value?.cards?.backup?.title ?? '备份数据库',
desc: locale.value?.cards?.backup?.desc ?? '导出系统数据备份文件',
title: '创建备份',
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
icon: Download,
colorClass: 'primary',
btnText: locale.value?.cards?.backup?.button ?? '创建备份'
color: 'blue',
btnText: '创建备份文件'
},
{
id: 'restore',
title: locale.value?.cards?.restore?.title ?? '恢复数据库',
desc: locale.value?.cards?.restore?.desc ?? '从备份文件恢复系统数据',
title: '恢复备份',
desc: '从之前导出的备份文件中恢复系统数据。',
icon: Upload,
colorClass: 'success',
btnText: locale.value?.cards?.restore?.button ?? '恢复备份'
color: 'emerald',
btnText: '选择备份文件'
},
{
id: 'reset-seq',
title: locale.value?.cards?.resetSeq?.title ?? '修复数据序列',
desc: locale.value?.cards?.resetSeq?.desc ?? '修复数据表自增序列',
title: '重置序列',
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
icon: RotateCw,
colorClass: 'warning',
btnText: locale.value?.cards?.resetSeq?.button ?? '修复序列'
color: 'amber',
btnText: '开始重置序列'
},
{
id: 'reset-db',
title: locale.value?.cards?.resetDb?.title ?? '重置数据库',
desc: locale.value?.cards?.resetDb?.desc ?? '清空除管理员外的系统数据',
title: '重置数据库',
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
icon: Trash2,
colorClass: 'error',
btnText: locale.value?.cards?.resetDb?.button ?? '重置数据库',
color: 'rose',
btnText: '立即重置数据库',
isDanger: true
},
{
id: 'auto-backup',
title: locale.value?.cards?.autoBackup?.title ?? '自动备份',
desc: locale.value?.cards?.autoBackup?.desc ?? '配置多种备份方式,通过外部触发实现定时自动备份',
icon: CloudUpload,
colorClass: 'success',
btnText: locale.value?.cards?.autoBackup?.button ?? '配置自动备份'
}
])
]
// 备份选项
const backupOptions = computed(() => [
const backupOptions = [
{
key: 'includeSongs',
label: locale.value?.backupOptions?.songs?.label ?? '歌曲与排期数据',
desc: locale.value?.backupOptions?.songs?.desc ?? '包含歌曲、投稿及排期数据'
label: '歌曲与排期数据',
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
},
{
key: 'includeSystemData',
label: locale.value?.backupOptions?.system?.label ?? '系统配置',
desc: locale.value?.backupOptions?.system?.desc ?? '包含站点设置和全局参数'
label: '系统配置信息',
desc: '包含站点设置、黑名单、播出时段等全局参数'
},
{
key: 'includeUsers',
label: locale.value?.backupOptions?.users?.label ?? '用户账户',
desc: locale.value?.backupOptions?.users?.desc ?? '包含用户账户和权限数据'
label: '用户账户数据',
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
}
])
]
// 表单数据
const createForm = ref({
@@ -582,9 +524,37 @@ const restoreForm = ref({
})
const sequenceForm = ref({
table: 'all'
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
@@ -614,8 +584,8 @@ const parseBackupSuperAdmin = async (file) => {
} catch (error) {
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
showNotification(getMessage('parseBackupFailed'), 'error')
console.error(getLogMessage('parseBackupFailed'), error)
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
console.error('解析备份文件失败:', error)
}
}
@@ -625,7 +595,7 @@ const handleFileSelect = async (event) => {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification(getMessage('invalidJsonFile'), 'error')
showNotification('请选择有效的JSON备份文件', 'error')
}
}
@@ -635,7 +605,7 @@ const handleFileDrop = async (event) => {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification(getMessage('invalidJsonFile'), 'error')
showNotification('请选择有效的JSON备份文件', 'error')
}
}
@@ -649,11 +619,11 @@ const createBackup = async () => {
} else if (createForm.value.includeUsers) {
tables = 'users'
} else if (createForm.value.includeSongs) {
tables = ['songs', 'scheduleSongPool']
tables = 'songs'
} else if (createForm.value.includeSystemData) {
tables = ['systemSettings']
} else {
throw new Error(getErrorMessage('noBackupContent'))
throw new Error('请至少选择一项备份内容')
}
const response = await $fetch('/api/admin/backup/export', {
@@ -677,7 +647,7 @@ const createBackup = async () => {
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(getMessage('backupDownloaded'), 'success')
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
@@ -685,7 +655,7 @@ const createBackup = async () => {
method: 'GET',
credentials: 'include'
})
if (!downloadResponse.ok) throw new Error(getErrorMessage('downloadFailed', downloadResponse.status))
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
const blob = await downloadResponse.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -695,25 +665,25 @@ const createBackup = async () => {
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification(getMessage('backupDownloaded'), 'success')
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
}
} else {
throw new Error(response.message || getErrorMessage('createBackupFailed'))
throw new Error(response.message || '备份创建失败')
}
} catch (error) {
console.error(getLogMessage('createBackupFailed'), error)
showNotification(getErrorMessage('createBackupFailedWithMessage', error?.message), 'error')
console.error('创建备份失败:', error)
showNotification('创建备份失败: ' + error.message, 'error')
} finally {
createLoading.value = false
}
}
const restoreBackup = async () => {
if (!selectedFile.value) return showNotification(getMessage('selectBackupFile'), 'error')
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
uploadLoading.value = true
restoreProgress.value = getProgressMessage('readingFile')
restoreProgress.value = '正在读取文件...'
try {
const fileContent = await selectedFile.value.text()
@@ -721,10 +691,10 @@ const restoreBackup = async () => {
try {
backupData = JSON.parse(fileContent)
} catch {
throw new Error(getErrorMessage('invalidBackupFormat'))
throw new Error('无法解析备份文件,文件格式不正确')
}
if (!backupData.data) throw new Error(getErrorMessage('missingBackupData'))
if (!backupData.data) throw new Error('备份文件缺少数据部分')
const fileHasSuperAdmin = hasSuperAdminInBackup.value
if (!fileHasSuperAdmin) {
restoreForm.value.overwriteSuperAdmin = false
@@ -734,7 +704,7 @@ const restoreBackup = async () => {
let temporaryPreservedUserId = null
if (restoreForm.value.mode === 'replace') {
restoreProgress.value = getProgressMessage('clearingData')
restoreProgress.value = '正在清空现有数据...'
const clearResult = await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
@@ -742,7 +712,7 @@ const restoreBackup = async () => {
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!clearResult.success) throw new Error(clearResult.message || getErrorMessage('clearDataFailed'))
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
}
@@ -754,7 +724,6 @@ const restoreBackup = async () => {
'semesters',
'playTimes',
'songs',
'scheduleSongPool',
'votes',
'schedules',
'notificationSettings',
@@ -788,14 +757,7 @@ const restoreBackup = async () => {
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 = getProgressMessage(
'restoringTable',
tableName,
i + 1,
Math.min(i + CHUNK_SIZE, records.length),
records.length,
progressPercent
)
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',
@@ -809,7 +771,7 @@ const restoreBackup = async () => {
}
})
if (!response.success) throw new Error(response.message || getErrorMessage('restoreTableFailed', tableName))
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)
@@ -818,21 +780,21 @@ const restoreBackup = async () => {
}
}
restoreProgress.value = getProgressMessage('fixingSequence')
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 || getErrorMessage('fixSequenceFailed'))
throw new Error(sequenceResult.message || sequenceResult.error || '序列修复失败')
}
restoreProgress.value = getProgressMessage('reloadingSmtp')
restoreProgress.value = '正在重载SMTP配置...'
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!smtpReloadResult.success) {
throw new Error(smtpReloadResult.message || getErrorMessage('smtpReloadFailed'))
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
}
const shouldFinalizeTempUser =
@@ -844,7 +806,7 @@ const restoreBackup = async () => {
if (shouldFinalizeTempUser) {
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
restoreProgress.value = getProgressMessage('finalizingAdmin')
restoreProgress.value = '正在完成管理员替换...'
await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
@@ -852,7 +814,7 @@ const restoreBackup = async () => {
}
})
}
showNotification(getMessage('restoreSuccessRelogin'), 'success')
showNotification('数据库恢复成功,正在重新登录', 'success')
activeModal.value = 'none'
setTimeout(() => {
if (auth.logout) {
@@ -865,11 +827,11 @@ const restoreBackup = async () => {
return
}
showNotification(getMessage('restoreSuccess'), 'success')
showNotification('数据库恢复成功', 'success')
activeModal.value = 'none'
} catch (error) {
console.error(getLogMessage('restoreBackupFailed'), error)
showNotification(getErrorMessage('restoreBackupFailedWithMessage', error?.message), 'error')
console.error('恢复备份失败:', error)
showNotification('恢复备份失败: ' + error.message, 'error')
} finally {
uploadLoading.value = false
restoreProgress.value = ''
@@ -880,19 +842,20 @@ 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: sequenceForm.value.table }
body: { table: tableValue }
})
if (response.success) {
showNotification(response.message || getMessage('sequenceResetSuccess'), 'success')
showNotification(response.message || '序列重置成功', 'success')
activeModal.value = 'none'
} else {
throw new Error(response.error || response.message || getErrorMessage('resetFailed'))
throw new Error(response.error || response.message || '重置失败')
}
} catch (error) {
console.error(getLogMessage('resetSequenceFailed'), error)
showNotification(getErrorMessage('resetSequenceFailedWithMessage', error?.message), 'error')
console.error('重置序列失败:', error)
showNotification('重置序列失败: ' + error.message, 'error')
} finally {
sequenceLoading.value = false
}
@@ -904,15 +867,15 @@ const resetDatabase = async () => {
try {
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
if (response.success) {
showNotification(getMessage('databaseResetSuccess'), 'success')
showNotification('数据库已成功重置', 'success')
activeModal.value = 'none'
setTimeout(() => window.location.reload(), 1500)
} else {
throw new Error(response.message || getErrorMessage('resetFailed'))
throw new Error(response.message || '重置失败')
}
} catch (error) {
console.error(getLogMessage('resetDatabaseFailed'), error)
showNotification(getErrorMessage('resetDatabaseFailedWithMessage', error?.message), 'error')
console.error('重置数据库失败:', error)
showNotification('重置数据库失败: ' + error.message, 'error')
} finally {
resetLoading.value = false
}
+69 -89
View File
@@ -1,48 +1,48 @@
<template>
<div class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] overflow-hidden">
<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-border-secondary bg-bg-secondary-20">
<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-primary-hover-10 flex items-center justify-center border border-primary-20"
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-primary" />
<Mail :size="20" class="text-blue-500" />
</div>
<div>
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.title }}</h3>
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.desc }}</p>
<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-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-[11px] font-bold rounded-xl transition-all"
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 ? locale.refreshPreview : locale.livePreview }}
<Eye :size="14" /> {{ previewHtml ? '刷新预览' : '实时预览' }}
</button>
<button
v-if="selected"
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-[11px] font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
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 ? locale.saving : locale.updateTemplate }}
<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-border-secondary bg-bg-secondary-10 flex flex-col">
<div class="p-4 border-b border-border-secondary-50">
<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-text-disabled" />
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
<input
type="text"
:placeholder="locale.searchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-9 pr-3 py-2 text-[10px] text-text-tertiary focus:outline-none focus:border-primary-30"
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>
@@ -53,8 +53,8 @@
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
:class="[
selectedKey === t.key
? 'bg-primary-hover-10 border-primary-20'
: 'bg-transparent border-transparent hover:bg-bg-tertiary-40'
? 'bg-blue-600/10 border-blue-500/20'
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
]"
@click="select(t)"
>
@@ -63,8 +63,8 @@
class="text-[11px] font-black tracking-tight transition-colors"
:class="
selectedKey === t.key
? 'text-primary'
: 'text-text-secondary group-hover:text-text-primary'
? 'text-blue-400'
: 'text-zinc-300 group-hover:text-zinc-100'
"
>
{{ t.name }}
@@ -72,69 +72,67 @@
<div class="flex gap-1">
<span
v-if="t.isBuiltin && !t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-bg-tertiary text-[8px] font-black text-text-tertiary uppercase tracking-tighter"
class="px-1.5 py-0.5 rounded-md bg-zinc-800 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
>
{{ locale.builtin }}
内置
</span>
<span
v-if="t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-success-10 text-[8px] font-black text-success uppercase tracking-tighter"
class="px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-[8px] font-black text-emerald-500 uppercase tracking-tighter"
>
{{ locale.custom }}
自定义
</span>
</div>
</div>
<span class="text-[9px] font-bold text-text-disabled font-mono">{{ t.key }}</span>
<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-bg-primary-20 overflow-hidden">
<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">
<div class="flex items-center justify-between px-1">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
>{{ locale.templateName }}</label
>
</div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>模板名称</label
>
<input
v-model="form.name"
type="text"
class="h-11 w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-text-disabled uppercase tracking-widest"
>{{ locale.emailSubject }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>邮件主题</label
>
<span class="text-[9px] text-text-tertiary font-bold uppercase">{{ locale.supportsVariables }}</span>
<span class="text-[9px] text-zinc-500 font-bold uppercase">支持变量</span>
</div>
<input
v-model="form.subject"
type="text"
:placeholder="locale.subjectPlaceholder"
class="h-11 w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-primary-hover-5 border border-primary-10 rounded-2xl p-4 flex gap-4">
<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-primary-10 flex items-center justify-center shrink-0"
class="w-8 h-8 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0"
>
<Info :size="16" class="text-primary" />
<Info :size="16" class="text-blue-500" />
</div>
<div class="space-y-1">
<h4 class="text-[10px] font-black text-primary uppercase tracking-widest">
{{ locale.availableVariables }}
<h4 class="text-[10px] font-black text-blue-400 uppercase tracking-widest">
可用变量
</h4>
<p class="text-[11px] text-text-tertiary leading-relaxed font-mono">
<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>
@@ -149,32 +147,32 @@
<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-text-disabled uppercase tracking-widest"
>{{ locale.htmlContent }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>HTML 内容</label
>
<span
class="px-2 py-0.5 rounded-md bg-bg-secondary text-[8px] font-black text-text-tertiary uppercase"
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-error hover:text-error transition-colors"
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" /> {{ locale.restoreDefault }}
<RotateCcw :size="12" /> 恢复默认配置
</button>
</div>
<div class="relative group">
<div
class="absolute right-4 top-4 p-2 rounded-lg bg-bg-secondary-50 border border-border-secondary opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
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-text-tertiary" />
<Code :size="14" class="text-zinc-500" />
</div>
<textarea
v-model="form.html"
rows="16"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl p-6 text-[11px] text-text-secondary font-mono leading-relaxed focus:outline-none focus:border-primary-30 custom-scrollbar"
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>
@@ -186,22 +184,22 @@
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-border-secondary">
<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-text-disabled uppercase tracking-widest flex items-center gap-2"
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
>
<Eye :size="14" /> {{ locale.livePreviewLabel }}
<span class="text-text-tertiary font-normal normal-case">{{ previewSubject }}</span>
<Eye :size="14" /> 实时预览:
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
</label>
<button
class="text-[10px] font-bold text-text-tertiary hover:text-text-secondary"
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300"
@click="previewHtml = ''"
>
{{ locale.hidePreview }}
隐藏预览
</button>
</div>
<div class="rounded-2xl overflow-hidden border border-border-secondary bg-bg-secondary shadow-2xl">
<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>
@@ -212,14 +210,14 @@
<!-- 空状态 -->
<div
v-else
class="flex-1 flex flex-col items-center justify-center text-text-secondary space-y-4"
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-bg-secondary-50 flex items-center justify-center border border-border-secondary-50"
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-text-primary" />
<Mail :size="32" class="text-zinc-800" />
</div>
<p class="text-xs font-bold tracking-widest uppercase">{{ locale.emptySelectTemplate }}</p>
<p class="text-xs font-bold tracking-widest uppercase">请在左侧选择一个邮件模板</p>
</div>
</div>
</div>
@@ -229,7 +227,6 @@
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from '@lucide/vue'
type TemplateItem = {
@@ -242,18 +239,6 @@ type TemplateItem = {
}
const { success, error } = useToast()
const { admin } = useLocale()
const locale = computed(() => {
const base = admin.value?.emailTemplateManager || {}
return {
...base,
messages: { ...(base.messages || {}) },
errors: { ...(base.errors || {}) }
}
})
const getLocaleText = (section: string, key: string, fallback = '') => {
return locale.value?.[section]?.[key] || fallback
}
const templates = ref<TemplateItem[]>([])
const selectedKey = ref<string>('')
const form = ref<{ key: string; name: string; subject: string; html: string }>({
@@ -293,10 +278,10 @@ async function save() {
try {
saving.value = true
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
success(getLocaleText('messages', 'saved', '模板已保存'))
success('模板已保存')
await loadList()
} catch (e: any) {
error(e?.data?.message || getLocaleText('errors', 'saveFailed', '保存失败'))
error(e?.data?.message || '保存失败')
} finally {
saving.value = false
}
@@ -308,10 +293,10 @@ async function restore() {
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
method: 'DELETE'
})
success(getLocaleText('messages', 'restored', '已恢复默认模板'))
success('已恢复默认模板')
await loadList()
} catch (e: any) {
error(e?.data?.message || getLocaleText('errors', 'restoreFailed', '恢复失败'))
error(e?.data?.message || '恢复失败')
} finally {
saving.value = false
}
@@ -321,15 +306,10 @@ async function doPreview() {
try {
const defaultData =
form.value.key === 'verification.code'
? {
name: locale.value?.previewData?.name || 'Example User',
email: 'example@school.edu',
code: '123456',
expiresInMinutes: 5
}
? { name: '张三', email: 'example@school.edu', code: '123456', expiresInMinutes: 5 }
: {
title: locale.value?.previewData?.title || 'System Notification',
message: locale.value?.previewData?.message || 'This is preview content.\nLine breaks and links are supported.',
title: '系统通知',
message: '这是一条预览内容\n支持换行与链接',
actionUrl: 'https://example.com'
}
const res = await $fetch('/api/admin/email-templates/preview', {
@@ -339,7 +319,7 @@ async function doPreview() {
previewHtml.value = res.html
previewSubject.value = res.subject
} catch (e: any) {
error(e?.data?.message || getLocaleText('errors', 'previewFailed', '预览失败'))
error(e?.data?.message || '预览失败')
}
}
@@ -356,10 +336,10 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
background: #3f3f46;
}
</style>
+540
View File
@@ -0,0 +1,540 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-bold text-zinc-100 flex items-center gap-2">
<CalendarDays class="w-5 h-5 text-blue-400" />
期望播出时段设置
</h3>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition-colors"
@click="openAddModal"
>
新增开放日期
</button>
</div>
<div v-if="loading" class="text-center py-10">
<Loader2 class="w-6 h-6 animate-spin mx-auto text-zinc-500" />
</div>
<div v-else-if="dates.length === 0" class="text-center py-10 text-zinc-500 bg-zinc-900/50 rounded-xl border border-zinc-800">
暂未设置任何开放日期
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="date in dates" :key="date.id" class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex flex-col gap-3">
<div class="flex justify-between items-start">
<div class="font-bold text-zinc-100 flex items-center gap-2">
{{ date.date }}
<span class="text-xs font-normal text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">{{ date.weekday }}</span>
</div>
<button @click="openDeleteDialog(date)" class="text-red-400 hover:text-red-300 p-1 transition-colors" title="删除">
<Trash2 class="w-4 h-4" />
</button>
</div>
<div class="flex flex-col gap-2.5 bg-zinc-950/50 p-3 rounded-lg border border-zinc-800/50">
<div class="flex items-center justify-between">
<div class="text-xs text-zinc-500">开放范围</div>
<div class="text-xs font-mono text-blue-400 font-medium break-all text-right ml-4">{{ formatSequences(date.availableSequences) }}</div>
</div>
<div class="flex items-center justify-between">
<div class="text-xs text-zinc-500">序号状态</div>
<div class="text-xs flex gap-2">
<span class="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">空闲 {{ getAvailableCount(date) }}</span>
<span class="text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded border border-zinc-700">共 {{ getTotalCount(date) }}</span>
</div>
</div>
</div>
<!-- 新增详细占用情况列表 -->
<div v-if="date.takenSequences && date.takenSequences.length > 0" class="mt-1 pt-3 border-t border-zinc-800">
<div class="text-xs font-bold text-zinc-500 mb-2 flex items-center justify-between">
<span>已占用明细</span>
<span class="text-red-400">{{ date.takenSequences.length }} 个</span>
</div>
<div class="space-y-1.5 max-h-32 overflow-y-auto pr-1 custom-scrollbar">
<div
v-for="seq in date.takenSequences"
:key="seq"
class="flex items-center gap-2 text-[11px]"
>
<span class="w-5 h-5 rounded flex items-center justify-center bg-red-500/10 text-red-500 font-bold border border-red-500/20 shrink-0">{{ seq }}</span>
<span class="text-zinc-300 truncate" :title="date.takenDetails?.[seq]?.title">{{ date.takenDetails?.[seq]?.title || '未知歌曲' }}</span>
<span class="text-zinc-500 truncate" :title="date.takenDetails?.[seq]?.artist">- {{ date.takenDetails?.[seq]?.artist || '未知歌手' }}</span>
</div>
</div>
</div>
<div v-else class="mt-2 pt-3 border-t border-zinc-800 text-xs text-emerald-500/70 font-bold">
所有序号目前空闲
</div>
</div>
</div>
<!-- 新增/编辑弹窗 -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-lg p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-zinc-100">新增开放日期</h3>
</div>
<!-- Tabs -->
<div class="flex p-1 bg-zinc-950 rounded-lg mb-6">
<button
@click="activeTab = 'auto'"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'auto' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
>
批量自动分配
</button>
<button
@click="activeTab = 'manual'; nextTick(() => { setTimeout(() => { setStartSeq(manualFormData.startSeq); setEndSeq(manualFormData.endSeq) }, 100) })"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'manual' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
>
手动配置单日
</button>
</div>
<!-- 自动分配表单 -->
<div v-if="activeTab === 'auto'" class="space-y-4">
<div>
<label class="block text-xs font-bold text-zinc-500 mb-2">选择播出日期 (可多选)</label>
<AdminMultiDatePicker v-model="autoFormData.dates" />
<p class="text-[10px] text-zinc-600 mt-1">已选择 {{ autoFormData.dates.length }} 天</p>
</div>
<div>
<label class="block text-xs font-bold text-zinc-500 mb-1">总序号数量</label>
<input type="number" min="1" v-model="autoFormData.totalSequences" class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-zinc-200">
<div class="flex items-center gap-2 mt-2">
<button @click="autoFormData.totalSequences = 21" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">21</button>
<button @click="autoFormData.totalSequences = 28" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">28</button>
<button @click="autoFormData.totalSequences = 35" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">35</button>
</div>
</div>
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p class="text-xs text-blue-400 font-bold mb-1">自动分配预览:</p>
<p class="text-[10px] text-blue-300/80">
<span v-if="autoFormData.dates.length === 0">请先选择日期</span>
<span v-else>将会把 1 - {{ autoFormData.totalSequences }} 号平均分配到选中的 {{ autoFormData.dates.length }} 天中。每天约 {{ Math.ceil(autoFormData.totalSequences / autoFormData.dates.length) }} 个序号。</span>
</p>
</div>
<div class="flex gap-3 pt-4">
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
<button @click="saveAuto" :disabled="saving || autoFormData.dates.length === 0" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">批量保存</button>
</div>
</div>
<!-- 手动配置表单 -->
<div v-else class="space-y-4">
<div>
<label class="block text-xs font-bold text-zinc-500 mb-2">选择日期 (单选)</label>
<AdminDatePicker v-model="manualFormData.date" />
</div>
<div>
<label class="block text-xs font-bold text-zinc-500 mb-1">开放的空闲序号</label>
<div class="flex flex-col items-center bg-zinc-950 border border-zinc-800 rounded-xl p-4">
<!-- 模拟 iOS 滚轮 (这里用简化的网格选择代替复杂的滚轮组件以保证稳定性和易用性) -->
<div class="text-sm font-bold text-zinc-400 mb-3 w-full flex justify-between items-center">
<span>选择开放范围</span>
<span class="text-blue-400">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
</div>
<div class="flex gap-4 items-center w-full justify-center py-4">
<div class="flex flex-col items-center flex-1">
<span class="text-[10px] text-zinc-500 mb-2">起始序号</span>
<div ref="startScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleStartScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
<div class="h-[44px]"></div> <!-- Padding top -->
<div
v-for="n in 100"
:key="'start-'+n"
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
:class="manualFormData.startSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
@click="setStartSeq(n)"
:data-val="n"
>
{{ n }}
</div>
<div class="h-[44px]"></div> <!-- Padding bottom -->
</div>
</div>
<span class="text-zinc-500 font-bold">至</span>
<div class="flex flex-col items-center flex-1">
<span class="text-[10px] text-zinc-500 mb-2">结束序号</span>
<div ref="endScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleEndScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
<div class="h-[44px]"></div> <!-- Padding top -->
<div
v-for="n in 100"
:key="'end-'+n"
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
:class="manualFormData.endSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
@click="setEndSeq(n)"
:data-val="n"
>
{{ n }}
</div>
<div class="h-[44px]"></div> <!-- Padding bottom -->
</div>
</div>
</div>
<!-- 如果有特定跳过的序号,保留文本框以供进阶编辑 -->
<div class="w-full mt-4">
<div
class="flex justify-between items-center text-[10px] text-zinc-500 cursor-pointer py-2 hover:text-zinc-300 transition-colors"
@click="showAdvancedManual = !showAdvancedManual"
>
<span>进阶:自定义跳过某些序号</span>
<span :class="{'rotate-180': showAdvancedManual}" class="transition-transform">▼</span>
</div>
<div v-if="showAdvancedManual" class="mt-2">
<textarea v-model="manualFormData.sequences" rows="2" placeholder="例如: 1,2,3,5 (跳过了4)" class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 resize-none focus:outline-none focus:border-blue-500"></textarea>
<p class="text-[10px] text-zinc-600 mt-1">手动编辑后将以此处文本框内容为准</p>
</div>
</div>
</div>
<p class="text-[10px] text-zinc-600 mt-2">注意:手动配置会覆盖该日期原有的设置</p>
</div>
<div class="flex gap-3 pt-4">
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
<button @click="saveManual" :disabled="saving || !manualFormData.date" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">保存</button>
</div>
</div>
</div>
</div>
<ConfirmDialog
:show="showDeleteDialog"
title="确认删除"
:message="deleteDialogMessage"
type="danger"
confirm-text="确认删除"
cancel-text="取消"
:loading="deleting"
@confirm="confirmDelete"
@update:show="showDeleteDialog = $event"
/>
</div>
</template>
<script setup>
import { ref, onMounted, nextTick, computed } from 'vue'
import { CalendarDays, Trash2, Loader2 } from '@lucide/vue'
import AdminMultiDatePicker from './AdminMultiDatePicker.vue'
import AdminDatePicker from './AdminDatePicker.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const dates = ref([])
const loading = ref(true)
const showModal = ref(false)
const saving = ref(false)
const activeTab = ref('auto')
const showAdvancedManual = ref(false)
const showDeleteDialog = ref(false)
const deleteTarget = ref(null)
const deleting = ref(false)
const deleteDialogMessage = computed(() => {
if (!deleteTarget.value) return '确定要删除该开放日期吗?此操作不可撤销。'
const dateStr = deleteTarget.value.date || ''
return `确定要删除开放日期 ${dateStr} 吗?此操作不可撤销。`
})
const formatSequences = (seqStr) => {
if (!seqStr) return '无'
const seqs = seqStr.split(',').map(Number).filter(n => !isNaN(n)).sort((a, b) => a - b)
if (seqs.length === 0) return '无'
const ranges = []
let start = seqs[0]
let prev = seqs[0]
for (let i = 1; i < seqs.length; i++) {
if (seqs[i] === prev + 1) {
prev = seqs[i]
} else {
ranges.push(start === prev ? `${start}` : `${start}-${prev}`)
start = seqs[i]
prev = seqs[i]
}
}
ranges.push(start === prev ? `${start}` : `${start}-${prev}`)
return ranges.join(', ')
}
const getTotalCount = (date) => {
return date.availableSequences ? date.availableSequences.split(',').filter(Boolean).length : 0
}
const getTakenCount = (date) => {
return date.takenSequences ? date.takenSequences.length : 0
}
const getAvailableCount = (date) => {
return Math.max(0, getTotalCount(date) - getTakenCount(date))
}
const startScrollRef = ref(null)
const endScrollRef = ref(null)
let scrollTimeoutStart = null
let scrollTimeoutEnd = null
const autoFormData = ref({
dates: [],
totalSequences: 28
})
const manualFormData = ref({
date: new Date().toISOString().split('T')[0],
startSeq: 1,
endSeq: 28,
sequences: ''
})
const fetchDates = async () => {
loading.value = true
try {
const res = await $fetch('/api/admin/expected-play-dates', {
...auth.getAuthConfig(),
query: { t: Date.now() }
})
dates.value = res
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
const setStartSeq = (val) => {
manualFormData.value.startSeq = val
if (startScrollRef.value) {
const el = startScrollRef.value.querySelector(`[data-val="${val}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
const setEndSeq = (val) => {
manualFormData.value.endSeq = val
if (endScrollRef.value) {
const el = endScrollRef.value.querySelector(`[data-val="${val}"]`)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}
const handleStartScroll = (e) => {
clearTimeout(scrollTimeoutStart)
scrollTimeoutStart = setTimeout(() => {
if (!startScrollRef.value) return
const container = startScrollRef.value
const center = container.scrollTop + container.clientHeight / 2
const items = Array.from(container.querySelectorAll('[data-val]'))
let closest = items[0]
let minDiff = Infinity
items.forEach(item => {
const itemCenter = item.offsetTop + item.clientHeight / 2
const diff = Math.abs(center - itemCenter)
if (diff < minDiff) {
minDiff = diff
closest = item
}
})
if (closest) {
const val = parseInt(closest.getAttribute('data-val'))
manualFormData.value.startSeq = val
}
}, 150)
}
const handleEndScroll = (e) => {
clearTimeout(scrollTimeoutEnd)
scrollTimeoutEnd = setTimeout(() => {
if (!endScrollRef.value) return
const container = endScrollRef.value
const center = container.scrollTop + container.clientHeight / 2
const items = Array.from(container.querySelectorAll('[data-val]'))
let closest = items[0]
let minDiff = Infinity
items.forEach(item => {
const itemCenter = item.offsetTop + item.clientHeight / 2
const diff = Math.abs(center - itemCenter)
if (diff < minDiff) {
minDiff = diff
closest = item
}
})
if (closest) {
const val = parseInt(closest.getAttribute('data-val'))
manualFormData.value.endSeq = val
}
}, 150)
}
const openAddModal = () => {
activeTab.value = 'auto'
autoFormData.value = { dates: [], totalSequences: 28 }
manualFormData.value = { date: new Date().toISOString().split('T')[0], startSeq: 1, endSeq: 28, sequences: '' }
showAdvancedManual.value = false
showModal.value = true
// 延时滚动到对应位置
nextTick(() => {
setTimeout(() => {
setStartSeq(manualFormData.value.startSeq)
setEndSeq(manualFormData.value.endSeq)
}, 100)
})
}
const saveAuto = async () => {
if (autoFormData.value.dates.length === 0 || !autoFormData.value.totalSequences) return
saving.value = true
const selectedDates = [...autoFormData.value.dates].sort()
const totalDays = selectedDates.length
const maxSeq = parseInt(autoFormData.value.totalSequences)
const seqsPerDay = Math.ceil(maxSeq / totalDays)
try {
for (let i = 0; i < selectedDates.length; i++) {
const date = selectedDates[i]
const startSeq = i * seqsPerDay + 1
const endSeq = Math.min((i + 1) * seqsPerDay, maxSeq)
const seqs = []
for (let j = startSeq; j <= endSeq; j++) {
seqs.push(j)
}
if (seqs.length > 0) {
await $fetch('/api/admin/expected-play-dates', {
method: 'POST',
body: {
date: date,
availableSequences: seqs.join(',')
},
...auth.getAuthConfig()
})
}
}
showModal.value = false
fetchDates()
if (window.$showNotification) {
window.$showNotification('批量自动分配成功', 'success')
}
} catch (e) {
if (window.$showNotification) {
window.$showNotification(e.data?.message || '保存失败', 'error')
}
} finally {
saving.value = false
}
}
const saveManual = async () => {
let finalSequences = manualFormData.value.sequences
if (!showAdvancedManual.value || !finalSequences) {
const start = Math.min(manualFormData.value.startSeq, manualFormData.value.endSeq)
const end = Math.max(manualFormData.value.startSeq, manualFormData.value.endSeq)
finalSequences = Array.from({ length: end - start + 1 }, (_, i) => start + i).join(',')
}
if (!manualFormData.value.date || !finalSequences) return
saving.value = true
try {
await $fetch('/api/admin/expected-play-dates', {
method: 'POST',
body: {
date: manualFormData.value.date,
availableSequences: finalSequences
},
...auth.getAuthConfig()
})
showModal.value = false
fetchDates()
if (window.$showNotification) {
window.$showNotification('手动配置成功', 'success')
}
} catch (e) {
if (window.$showNotification) {
window.$showNotification(e.data?.message || '保存失败', 'error')
}
} finally {
saving.value = false
}
}
const deleteDate = async (id) => {
try {
await $fetch(`/api/admin/expected-play-dates/${id}`, {
method: 'DELETE',
...auth.getAuthConfig()
})
fetchDates()
if (window.$showNotification) {
window.$showNotification('删除成功', 'success')
}
} catch (e) {
console.error(e)
if (window.$showNotification) {
window.$showNotification(e.data?.message || '删除失败', 'error')
}
}
}
const openDeleteDialog = (date) => {
deleteTarget.value = date
showDeleteDialog.value = true
}
const confirmDelete = async () => {
if (!deleteTarget.value?.id) return
deleting.value = true
try {
await deleteDate(deleteTarget.value.id)
showDeleteDialog.value = false
deleteTarget.value = null
} finally {
deleting.value = false
}
}
onMounted(fetchDates)
</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>
+190 -313
View File
@@ -2,82 +2,45 @@
<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-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">{{ locale.desc }}</p>
<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-bg-secondary-30 border border-border-secondary rounded-[2rem] p-8 shadow-xl">
<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-text-disabled uppercase tracking-[0.2em] px-1"
>{{ locale.notificationTitle }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知标题</label
>
<input
v-model="form.title"
type="text"
:placeholder="locale.titlePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary placeholder:text-text-primary"
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-text-disabled uppercase tracking-[0.2em] px-1"
>{{ locale.notificationContent }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知内容</label
>
<textarea
v-model="form.content"
:placeholder="locale.contentPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary placeholder:text-text-primary min-h-[160px] resize-none"
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"
/>
<p class="px-1 text-[10px] font-medium text-text-disabled">
{{ locale.markdownHint }}
</p>
</div>
<!-- 重要通知开关 -->
<label
class="flex cursor-pointer items-start gap-4 rounded-2xl border p-4 shadow-sm transition-colors"
:class="
form.important
? 'border-primary-300 bg-primary-10'
: 'border-border-tertiary bg-bg-primary hover:border-border-quaternary hover:bg-bg-secondary'
"
>
<span
class="relative mt-0.5 inline-block h-6 w-11 shrink-0"
>
<input v-model="form.important" type="checkbox" class="peer sr-only">
<span
class="absolute inset-0 rounded-full bg-border-tertiary transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary-300"
/>
<span
class="absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-5"
/>
</span>
<span class="min-w-0">
<span class="flex items-center gap-2 text-sm font-black text-text-primary">
<AlertTriangle
:size="16"
:class="form.important ? 'text-primary' : 'text-text-tertiary'"
/>
{{ locale.importantToggleTitle }}
</span>
<span class="mt-1 block text-xs leading-relaxed text-text-secondary">
{{ locale.importantToggleDescription }}
</span>
</span>
</label>
<!-- 范围选择 -->
<div class="space-y-3 pt-4 border-t border-border-secondary-50">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
>{{ locale.scope }}</label
<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
@@ -86,8 +49,8 @@
: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-primary-hover border-primary text-text-primary shadow-lg shadow-[var(--primary-glow)]'
: 'bg-bg-primary border-border-secondary text-text-tertiary hover:border-border-tertiary hover:text-text-secondary'
? '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"
>
@@ -103,26 +66,24 @@
<div
v-if="form.scope === 'ALL'"
key="all"
class="p-4 bg-primary-5 border border-primary-10 rounded-2xl flex items-center gap-4"
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-2xl flex items-center gap-4"
>
<Info class="text-primary shrink-0" :size="18" />
<p class="text-[11px] font-bold text-text-tertiary">
{{ locale.allUsersHint }}
<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-text-disabled uppercase tracking-widest px-1"
>{{ locale.selectGrade }}</span
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择年级</span
>
<CustomSelect
v-model="form.grade"
:options="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectGradePlaceholder"
class-name="w-full md:w-64"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full md:w-64"
/>
</div>
</div>
@@ -133,61 +94,55 @@
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.grade }}</span
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>年级</span
>
<CustomSelect
v-model="form.classGrade"
:options="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectGradePlaceholder"
class-name="w-full"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full"
/>
</div>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.className }}</span
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>班级</span
>
<CustomSelect
v-model="form.className"
:options="classOptionsForClassScope"
:options="classNumberOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectClassPlaceholder"
placeholder="请选择班级"
class-name="w-full"
:disabled="!form.classGrade"
/>
</div>
</div>
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
<div class="p-6 bg-bg-primary-50 border border-border-secondary border-dashed rounded-2xl">
<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="gradeOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectGradePlaceholder"
class-name="w-full"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full"
/>
<div class="flex gap-2">
<CustomSelect
v-model="multiClassForm.class"
:options="classOptionsForMultiClassScope"
:options="classNumberOptions"
label-key="label"
value-key="value"
:placeholder="locale.selectClassPlaceholder"
placeholder="请选择班级"
class-name="flex-1"
:disabled="!multiClassForm.grade"
/>
<button
:disabled="!canAddClass"
class="px-4 bg-bg-tertiary hover:bg-bg-quaternary disabled:opacity-50 disabled:cursor-not-allowed text-text-tertiary font-bold rounded-xl text-xs transition-all"
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"
>
{{ locale.add }}
添加
</button>
</div>
</div>
@@ -196,13 +151,13 @@
<div
v-for="(cls, index) in form.selectedClasses"
:key="index"
class="flex items-center gap-2 px-3 py-1.5 bg-bg-secondary border border-border-secondary rounded-lg group"
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-text-tertiary"
<span class="text-[10px] font-bold text-zinc-400"
>{{ cls.grade }} {{ cls.class }}</span
>
<button
class="text-text-disabled hover:text-error transition-colors"
class="text-zinc-600 hover:text-red-400 transition-colors"
@click="removeClassFromSelection(index)"
>
<X :size="12" />
@@ -210,8 +165,8 @@
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-text-secondary uppercase tracking-widest">
{{ locale.noClassSelected }}
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
未选择任何班级
</p>
</div>
</div>
@@ -221,19 +176,19 @@
<div class="relative flex items-center">
<Search
v-if="!userSearchLoading"
class="absolute left-4 text-text-secondary pointer-events-none"
class="absolute left-4 text-zinc-700 pointer-events-none"
:size="16"
/>
<Loader2
v-else
class="absolute left-4 text-primary animate-spin pointer-events-none"
class="absolute left-4 text-blue-500 animate-spin pointer-events-none"
:size="16"
/>
<input
v-model="userSearchQuery"
type="text"
:placeholder="locale.userSearchPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary"
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>
@@ -241,42 +196,42 @@
<!-- 搜索结果 -->
<div
v-if="showUserSearchResults && userSearchResults.length > 0"
class="bg-bg-primary border border-border-secondary rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
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-border-secondary-60 bg-bg-secondary-40">
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest"
>{{ getLocaleMessage('searchResults', userSearchResults.length) }}</span
<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-border-secondary-30 last:border-0 hover:bg-bg-tertiary-30 transition-all"
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-text-primary">
<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-primary font-black"
<span class="text-[10px] text-blue-500 font-black"
>@{{ user.username }}</span
>
<span
v-if="user.grade && user.class"
class="text-[10px] text-text-disabled font-bold uppercase tracking-widest"
class="text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
>{{ user.grade }} {{ user.class }}</span
>
<span class="text-[10px] text-text-secondary font-black">{{
<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-bg-tertiary hover:bg-primary-hover hover:text-text-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-tertiary text-[10px] font-black rounded-lg transition-all uppercase"
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) ? locale.selected : locale.select }}
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
</button>
</div>
</div>
@@ -284,30 +239,30 @@
<!-- 已选择的用户 -->
<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-text-disabled uppercase tracking-widest"
>{{ getLocaleMessage('selectedUsers', form.selectedUsers.length) }}</span
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>已选择用户 ({{ form.selectedUsers.length }})</span
>
<button
class="text-[9px] font-black text-error-70 hover:text-error uppercase tracking-widest transition-colors"
class="text-[9px] font-black text-red-500/70 hover:text-red-500 uppercase tracking-widest transition-colors"
@click="clearAllSelectedUsers"
>
{{ locale.clearAll }}
清空全部
</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-bg-secondary border border-border-secondary rounded-lg group"
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-text-secondary leading-none">{{
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
user.name || user.username
}}</span>
<span class="text-[8px] text-text-disabled">@{{ user.username }}</span>
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
</div>
<button
class="text-text-disabled hover:text-error transition-colors"
class="text-zinc-600 hover:text-red-400 transition-colors"
@click="removeUserFromSelection(index)"
>
<X :size="12" />
@@ -316,8 +271,8 @@
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-text-secondary uppercase tracking-widest">
{{ locale.noUsersSelected }}
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
未选择任何用户
</p>
</div>
</div>
@@ -329,17 +284,17 @@
<div v-if="error || success" class="mt-6">
<div
v-if="error"
class="p-4 bg-error-5 border border-error-10 rounded-2xl flex items-center gap-3"
class="p-4 bg-red-500/5 border border-red-500/10 rounded-2xl flex items-center gap-3"
>
<AlertCircle class="text-error shrink-0" :size="18" />
<p class="text-xs font-bold text-error">{{ error }}</p>
<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-success-5 border border-success-10 rounded-2xl flex items-center gap-3"
class="p-4 bg-green-500/5 border border-green-500/10 rounded-2xl flex items-center gap-3"
>
<Check class="text-success shrink-0" :size="18" />
<p class="text-xs font-bold text-success">{{ success }}</p>
<Check class="text-green-500 shrink-0" :size="18" />
<p class="text-xs font-bold text-green-400">{{ success }}</p>
</div>
</div>
@@ -347,16 +302,16 @@
<button
:disabled="loading || !isFormValid"
:class="[
'flex items-center gap-2 px-10 py-4 bg-bg-primary border border-border-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
'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-text-primary cursor-not-allowed'
: 'text-text-tertiary hover:text-text-primary hover:border-border-tertiary hover:bg-bg-secondary'
? '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 ? locale.sending : locale.send }}
{{ loading ? '发送中...' : '发送通知' }}
</button>
</div>
</div>
@@ -365,130 +320,97 @@
<!-- 右侧:预览区 -->
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
<div
class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
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-text-primary uppercase tracking-widest mb-6 flex items-center gap-2"
class="text-sm font-black text-zinc-100 uppercase tracking-widest mb-6 flex items-center gap-2"
>
<Eye :size="16" class="text-primary" /> {{ locale.previewTitle }}
<Eye :size="16" class="text-blue-500" /> 通知预览
</h3>
<div class="flex flex-1 flex-col items-center justify-center p-1 sm:p-4">
<!-- 重要通知预览 -->
<section
v-if="form.important"
class="w-full max-w-[320px] overflow-hidden rounded-2xl border border-primary-300 bg-bg-primary shadow-2xl"
>
<div class="h-1.5 w-full bg-warning" />
<div class="p-5 space-y-5">
<header class="flex items-start gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-warning-300 bg-warning-10 text-warning"
aria-hidden="true"
>
<Icon name="bell-ring" :size="17" />
</div>
<div class="min-w-0 flex-1">
<h4
class="break-words text-sm font-black"
:class="form.title ? 'text-text-primary' : 'italic text-text-disabled'"
>
{{ form.title || locale.previewTitlePlaceholder }}
</h4>
<p class="mt-1 truncate text-[11px] font-black text-text-disabled">
{{ locale.senderLabel }}:{{ previewSenderName }}
</p>
</div>
</header>
<div
v-if="form.content"
class="markdown-body max-h-64 overflow-y-auto text-sm leading-relaxed text-text-tertiary"
v-html="previewContent"
/>
<p v-else class="text-sm italic leading-relaxed text-text-disabled">
{{ locale.previewContentPlaceholder }}
</p>
<footer class="flex items-center justify-between gap-4 border-t border-border-secondary-50 pt-4">
<div class="flex min-w-0 items-center gap-1.5 text-text-secondary">
<Users :size="13" class="shrink-0" aria-hidden="true" />
<span class="truncate text-[10px] font-black text-text-disabled">
{{ getLocaleMessage('previewScope', scopeDescription) }}
</span>
</div>
<MessageSquare :size="17" class="shrink-0 text-warning" aria-hidden="true" />
</footer>
</div>
</section>
<!-- 普通通知预览 -->
<article
v-else
class="w-full max-w-[320px] overflow-hidden rounded-2xl border border-border-secondary bg-bg-primary shadow-2xl"
>
<div class="h-1.5 w-full bg-primary" />
<div class="p-5 space-y-5">
<header class="flex items-start gap-3">
<div
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary-20 bg-primary-hover-10 text-primary"
aria-hidden="true"
>
<Icon name="bell" :size="17" />
</div>
<div class="min-w-0 flex-1">
<h4
class="break-words text-sm font-black"
:class="form.title ? 'text-text-primary' : 'italic text-text-disabled'"
>
{{ form.title || locale.previewTitlePlaceholder }}
</h4>
<p class="mt-1 truncate text-[11px] font-black text-text-disabled">
{{ locale.senderLabel }}:{{ previewSenderName }}
</p>
</div>
</header>
<div
v-if="form.content"
class="markdown-body max-h-64 overflow-y-auto text-sm leading-relaxed text-text-tertiary"
v-html="previewContent"
/>
<p v-else class="text-sm italic leading-relaxed text-text-disabled">
{{ locale.previewContentPlaceholder }}
</p>
<footer class="flex items-center justify-between gap-4 border-t border-border-secondary-50 pt-4">
<div class="flex min-w-0 items-center gap-1.5 text-text-secondary">
<Users :size="13" class="shrink-0" aria-hidden="true" />
<span class="truncate text-[10px] font-black text-text-disabled">
{{ getLocaleMessage('previewScope', scopeDescription) }}
</span>
</div>
<MessageSquare :size="17" class="shrink-0 text-primary" aria-hidden="true" />
</footer>
</div>
</article>
<div class="flex-1 flex flex-col items-center justify-center p-4">
<div
class="mt-6 flex w-full max-w-[320px] items-start gap-3 rounded-lg border border-warning-10 bg-warning-5 p-3"
class="w-full max-w-[320px] bg-[#0c0c0e] border border-zinc-800 rounded-3xl overflow-hidden shadow-2xl relative"
>
<AlertCircle :size="14" class="mt-0.5 shrink-0 text-warning" />
<p class="text-[10px] font-black text-text-tertiary leading-normal">
{{ locale.previewHint }}
</p>
<!-- 顶部装饰 -->
<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>
<NotificationHistory :refresh-key="historyRefreshKey" />
</div>
</template>
<script setup>
import { computed, ref, onUnmounted, onMounted, watch } from 'vue'
import { computed, ref, onUnmounted } from 'vue'
import {
Send,
Users,
@@ -496,51 +418,27 @@ import {
LayoutGrid,
User,
Search,
Bell,
Info,
X,
Check,
Plus,
AlertCircle,
AlertTriangle,
Eye,
MessageSquare,
Loader2
} from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Icon from '~/components/UI/Icon.vue'
import NotificationHistory from '~/components/Admin/NotificationHistory.vue'
import { useAuth } from '~/composables/useAuth'
import { useAdmin } from '~/composables/useAdmin'
import { useUserFilters } from '~/composables/useUserFilters'
import { useLocale } from '~/utils/locale'
import { renderMarkdown } from '~/utils/markdown'
const { user: authUser, isAdmin, getAuthConfig } = useAuth()
const { isAdmin } = useAuth()
const { sendAdminNotification } = useAdmin()
const userFilters = useUserFilters()
const { admin } = useLocale()
const locale = computed(() => admin.value?.notificationSender || {})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
const previewSenderName = computed(
() =>
authUser.value?.name?.trim() ||
authUser.value?.username?.trim() ||
locale.value.previewSender
)
onMounted(() => {
userFilters.fetchOptions()
})
const gradeOptions = computed(() => {
return userFilters.getAvailableGrades().map(g => ({ label: g, value: g }))
})
// 表单数据
const form = ref({
title: '',
content: '',
important: false,
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
grade: '',
classGrade: '',
@@ -555,29 +453,9 @@ const multiClassForm = ref({
class: ''
})
const classOptionsForClassScope = computed(() => {
const classes = userFilters.getAvailableClasses(undefined, form.value.classGrade)
return classes.map(c => ({ label: c, value: c }))
})
const classOptionsForMultiClassScope = computed(() => {
const classes = userFilters.getAvailableClasses(undefined, multiClassForm.value.grade)
return classes.map(c => ({ label: c, value: c }))
})
watch(() => form.value.classGrade, () => {
form.value.className = ''
})
watch(() => multiClassForm.value.grade, () => {
multiClassForm.value.class = ''
})
const loading = ref(false)
const error = ref('')
const success = ref('')
const historyRefreshKey = ref(0)
const previewContent = computed(() => renderMarkdown(form.value.content))
// 用户搜索相关
const userSearchQuery = ref('')
@@ -586,13 +464,18 @@ const showUserSearchResults = ref(false)
const userSearchLoading = ref(false)
let userSearchTimeout = null
const targetOptions = computed(() => [
{ id: 'ALL', label: getNestedMessage('targets', 'all'), icon: Users },
{ id: 'GRADE', label: getNestedMessage('targets', 'grade'), icon: GraduationCap },
{ id: 'CLASS', label: getNestedMessage('targets', 'class'), icon: LayoutGrid },
{ id: 'MULTI_CLASS', label: getNestedMessage('targets', 'multiClass'), icon: Plus },
{ id: 'SPECIFIC_USERS', label: getNestedMessage('targets', 'specificUsers'), icon: User }
])
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 classNumberOptions = Array.from({ length: 16 }, (_, i) => ({
label: `${i + 1}班`,
value: (i + 1).toString()
}))
// 判断是否可以添加班级
const canAddClass = computed(() => {
@@ -650,8 +533,7 @@ const searchUsers = async (query) => {
query: {
search: query,
limit: 20
},
...getAuthConfig()
}
})
if (response.success) {
@@ -704,9 +586,9 @@ const clearAllSelectedUsers = () => {
// 获取角色文本
const getRoleText = (role) => {
const roleMap = {
admin: getNestedMessage('roles', 'admin'),
teacher: getNestedMessage('roles', 'teacher'),
student: getNestedMessage('roles', 'student')
admin: '管理员',
teacher: '教师',
student: '学生'
}
return roleMap[role] || role
}
@@ -740,23 +622,21 @@ const isFormValid = computed(() => {
const scopeDescription = computed(() => {
switch (form.value.scope) {
case 'ALL':
return getNestedMessage('scopeDescriptions', 'all')
return '全体用户'
case 'GRADE':
return form.value.grade
? getNestedMessage('scopeDescriptions', 'grade', form.value.grade)
: getNestedMessage('scopeDescriptions', 'selectGrade')
return form.value.grade ? `${form.value.grade}年级` : '请选择年级'
case 'CLASS':
return form.value.classGrade && form.value.className
? getNestedMessage('scopeDescriptions', 'class', form.value.classGrade, form.value.className)
: getNestedMessage('scopeDescriptions', 'selectClass')
? `${form.value.classGrade}年级${form.value.className}班`
: '请选择班级'
case 'MULTI_CLASS':
return form.value.selectedClasses.length > 0
? getNestedMessage('scopeDescriptions', 'multiClass', form.value.selectedClasses.length)
: getNestedMessage('scopeDescriptions', 'selectClass')
? `${form.value.selectedClasses.length}个班级`
: '请选择班级'
case 'SPECIFIC_USERS':
return form.value.selectedUsers.length > 0
? getNestedMessage('scopeDescriptions', 'specificUsers', form.value.selectedUsers.length)
: getNestedMessage('scopeDescriptions', 'selectUsers')
? `已选择${form.value.selectedUsers.length}个用户`
: '请选择用户'
default:
return ''
}
@@ -765,12 +645,12 @@ const scopeDescription = computed(() => {
// 发送通知
const sendNotification = async () => {
if (!isAdmin.value) {
error.value = getNestedMessage('errors', 'adminOnly')
error.value = '只有管理员可以发送系统通知'
return
}
if (!isFormValid.value) {
error.value = getNestedMessage('errors', 'incomplete')
error.value = '请填写完整信息'
return
}
@@ -783,7 +663,6 @@ const sendNotification = async () => {
const notificationData = {
title: form.value.title,
content: form.value.content,
important: form.value.important,
scope: form.value.scope,
filter: {}
}
@@ -804,8 +683,7 @@ const sendNotification = async () => {
const result = await sendAdminNotification(notificationData)
if (result && result.success) {
success.value = getNestedMessage('messages', 'sendSuccess', result.sentCount)
historyRefreshKey.value += 1
success.value = `成功发送通知给 ${result.sentCount} 名用户`
// 3秒后自动隐藏成功提示
setTimeout(() => {
@@ -816,7 +694,6 @@ const sendNotification = async () => {
form.value = {
title: '',
content: '',
important: false,
scope: 'ALL',
grade: '',
classGrade: '',
@@ -835,10 +712,10 @@ const sendNotification = async () => {
userSearchLoading.value = false
clearTimeout(userSearchTimeout)
} else {
throw new Error(result?.message || getNestedMessage('errors', 'sendFailed'))
throw new Error(result?.message || '发送通知失败')
}
} catch (err) {
error.value = err.message || getNestedMessage('errors', 'sendError')
error.value = err.message || '发送通知时发生错误'
console.error('发送通知错误:', err)
// 3秒后自动隐藏错误提示
@@ -865,11 +742,11 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
background: #3f3f46;
}
.fade-enter-active,
+87 -173
View File
@@ -5,21 +5,19 @@
<div
v-for="(stat, i) in statCards"
:key="i"
class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 relative overflow-hidden group hover:border-border-tertiary transition-all shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
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 flex items-center justify-center',
'p-3 rounded-xl border',
stat.color === 'blue'
? 'bg-primary-10 text-primary border-primary-20'
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
: stat.color === 'emerald'
? 'bg-success-10 text-success border-success-20'
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: stat.color === 'pink'
? 'bg-color-pink-alpha-10 text-color-pink border-color-pink-alpha-10'
: stat.color === 'warning'
? 'bg-warning-10 text-warning border-warning-20'
: 'bg-bg-quaternary-10 text-text-tertiary border-border-tertiary-20'
? '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" />
@@ -28,7 +26,7 @@
v-if="stat.trend"
:class="[
'flex items-center gap-1 text-[11px] font-bold',
stat.trendDown ? 'text-error' : 'text-success'
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
]"
>
<TrendingDown v-if="stat.trendDown" :size="12" />
@@ -37,8 +35,8 @@
</div>
</div>
<div>
<p class="text-text-tertiary text-sm font-medium">{{ stat.label }}</p>
<h4 class="text-3xl font-bold tracking-tight text-text-primary">{{ stat.value }}</h4>
<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>
@@ -46,19 +44,18 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 最近活动 -->
<div
class="lg:col-span-5 bg-bg-secondary-40 border border-border-secondary rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
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-border-secondary flex items-center justify-between">
<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-primary" /> {{ locale.recentActivities }}
<Activity :size="18" class="text-blue-500" /> 最近活动
</h3>
<button
class="p-2 text-text-tertiary hover:text-text-secondary transition-colors"
:disabled="loadingActivities"
class="p-2 text-zinc-500 hover:text-zinc-300 transition-colors"
:class="{ 'animate-spin': loadingActivities }"
@click="refreshActivities"
>
<RefreshCw v-if="!loadingActivities" :size="16" />
<Loader2 v-else :size="16" class="animate-spin" />
<RefreshCw :size="16" />
</button>
</div>
<div
@@ -66,23 +63,23 @@
>
<div
v-if="loadingActivities && recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-text-tertiary gap-3 py-20"
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
>
<Loader2 :size="24" class="animate-spin" />
<span class="text-sm">{{ locale.loading }}</span>
<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-text-tertiary gap-3 py-20"
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
>
<Inbox :size="24" />
<span class="text-sm">{{ locale.noActivities }}</span>
<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-bg-tertiary-40 transition-all cursor-pointer group"
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-zinc-800/40 transition-all cursor-pointer group"
>
<div
:class="[
@@ -94,14 +91,14 @@
</div>
<div class="flex-1 min-w-0">
<h5
class="font-bold text-sm text-text-primary group-hover:text-primary transition-colors"
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ activity.title }}
</h5>
<p class="text-xs text-text-tertiary truncate mt-1">{{ activity.description }}</p>
<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-text-disabled" />
<span class="text-[10px] text-text-disabled font-medium uppercase tracking-wider">{{
<Clock :size="10" class="text-zinc-600" />
<span class="text-[10px] text-zinc-600 font-medium uppercase tracking-wider">{{
formatTime(activity.createdAt)
}}</span>
</div>
@@ -113,21 +110,21 @@
<!-- 系统状态 -->
<div
class="lg:col-span-4 bg-bg-secondary-40 border border-border-secondary rounded-xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
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-border-secondary flex items-center justify-between">
<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-success" /> {{ locale.systemStatus }}
<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-success-10 text-success border-success-20'
: 'bg-error-10 text-error border-error-20'
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
]"
>
{{ systemStatus.online ? locale.online : locale.offline }}
{{ systemStatus.online ? '在线' : '离线' }}
</span>
</div>
<div class="p-6 space-y-6">
@@ -141,38 +138,24 @@
:class="[
'w-1.5 h-1.5 rounded-full',
status.active
? 'bg-success shadow-[0_0_10px_var(--success-70)]'
: 'bg-bg-quaternary'
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.7)]'
: 'bg-zinc-600'
]"
/>
<span class="text-xs font-semibold text-text-tertiary">{{ status.label }}</span>
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
</div>
<span class="text-xs font-bold text-text-primary">{{ status.value }}</span>
<span class="text-xs font-bold text-zinc-200">{{ status.value }}</span>
</div>
</div>
<div class="mt-auto border-t border-border-secondary px-6 py-4 flex flex-col items-center justify-center gap-1 text-center">
<span class="text-[10px] font-medium uppercase tracking-[0.3em] text-text-disabled">
{{ locale.instanceId }}
</span>
<button
type="button"
class="max-w-full text-xs text-text-tertiary hover:text-text-secondary transition-colors break-all leading-relaxed"
:title="instanceId || locale.noInstanceId"
:disabled="!instanceId"
@click="copyInstanceId"
>
{{ instanceId || locale.noInstanceId }}
</button>
</div>
</div>
<!-- 快速操作 -->
<div
class="lg:col-span-3 bg-bg-secondary-40 border border-border-secondary rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
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-border-secondary">
<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-warning" /> {{ locale.quickActions }}
<Zap :size="18" class="text-yellow-500" /> 快速操作
</h3>
</div>
<div class="p-6 space-y-3">
@@ -182,8 +165,8 @@
: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-primary-hover border-primary text-text-primary shadow-xl shadow-[var(--primary-glow)] hover:bg-primary'
: 'bg-bg-primary-40 border-border-secondary text-text-tertiary hover:border-border-tertiary hover:text-text-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)"
>
@@ -198,36 +181,31 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref, computed } from 'vue'
import {
Activity,
Ban,
Bell,
Calendar,
Clock,
ExternalLink,
Heart,
Inbox,
Loader2,
Music,
RefreshCw,
ShieldCheck,
Users,
Calendar,
Heart,
TrendingDown,
TrendingUp,
Users,
Zap
Activity,
ShieldCheck,
RefreshCw,
Clock,
Zap,
Bell,
Ban,
ExternalLink,
Inbox
} from '@lucide/vue'
import packageJson from '~~/package.json'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const emit = defineEmits(['navigate'])
const { success: showSuccess, error: showError } = useToast()
const { admin } = useLocale()
const locale = computed(() => useSafeLocale(admin.value?.overview || {}))
const { format: formatLocaleValue } = useLocaleText(locale)
const systemVersion = ref(packageJson.version)
const { siteTitle } = useSiteConfig()
const displayName = computed(() => siteTitle.value || '站点')
const stats = ref({
totalSongs: 0,
totalUsers: 0,
@@ -243,7 +221,6 @@ const stats = ref({
const recentActivities = ref([])
const loadingActivities = ref(false)
const instanceId = ref('')
const systemStatus = ref({
online: true,
@@ -254,7 +231,7 @@ const systemStatus = ref({
// 统计卡片数据
const statCards = computed(() => [
{
label: locale.value.statCards?.totalSongs || '总歌曲数',
label: '总歌曲数',
value: formatNumber(stats.value.totalSongs),
icon: Music,
color: 'blue',
@@ -262,19 +239,19 @@ const statCards = computed(() => [
trendDown: stats.value.songsChange < 0
},
{
label: locale.value.statCards?.registeredUsers || '注册用户',
label: '注册用户',
value: formatNumber(stats.value.totalUsers),
icon: Users,
color: 'emerald'
},
{
label: locale.value.statCards?.todaySchedules || '今日排班',
label: '今日排期',
value: formatNumber(stats.value.todaySchedules),
icon: Calendar,
color: 'warning'
color: 'zinc'
},
{
label: locale.value.statCards?.weeklyRequests || '本周点歌',
label: '本周点歌',
value: formatNumber(stats.value.weeklyRequests),
icon: Heart,
color: 'pink',
@@ -286,49 +263,48 @@ const statCards = computed(() => [
// 系统状态项
const statusItems = computed(() => [
{
label: locale.value.statusItems?.database || '数据库',
value: systemStatus.value.database ? locale.value.normal || '正常' : locale.value.abnormal || '异常',
label: '数据库连接',
value: systemStatus.value.database ? '正常' : '异常',
active: systemStatus.value.database
},
{
label: locale.value.statusItems?.api || 'API',
value: systemStatus.value.api ? locale.value.normal || '正常' : locale.value.abnormal || '异常',
label: 'API服务',
value: systemStatus.value.api ? '正常' : '异常',
active: systemStatus.value.api
},
{
label: locale.value.statusItems?.semester || '当前学期',
value: stats.value.currentSemester || locale.value.unset || '未设置',
label: '当前学期',
value: stats.value.currentSemester || '未设置',
active: !!stats.value.currentSemester
},
{
label: locale.value.statusItems?.blacklist || '黑名单',
value: formatLocaleValue(locale.value.itemUnit, String(stats.value.blacklistCount), stats.value.blacklistCount),
label: '黑名单项目',
value: `${stats.value.blacklistCount} 项`,
active: stats.value.blacklistCount >= 0
},
{ label: locale.value.statusItems?.version || '版本', value: `v${systemVersion.value}`, active: true }
{ label: '站点版本', value: `${displayName.value} v${systemVersion.value}`, active: true }
])
// 快速操作
const quickActions = computed(() => [
{ label: locale.value.actions?.schedule || '排班管理', icon: Calendar, id: 'schedule', primary: true },
{ label: locale.value.actions?.users || '用户管理', icon: Users, id: 'users' },
{ label: locale.value.actions?.notifications || '通知管理', icon: Bell, id: 'notifications' },
{ label: locale.value.actions?.blacklist || '黑名单', icon: Ban, id: 'blacklist' }
])
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`
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
const getActivityStyle = (type) => {
const styles = {
song: { icon: Music, bg: 'bg-info-10 text-info border-info-20' },
user: { icon: Users, bg: 'bg-color-pink-alpha-10 text-color-pink border-color-pink-alpha-20' },
schedule: { icon: Calendar, bg: 'bg-primary-10 text-primary border-primary-20' }
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-bg-quaternary-10 text-text-tertiary border-border-tertiary-20' }
return styles[type] || { icon: Activity, bg: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20' }
}
const loadStats = async () => {
@@ -342,60 +318,6 @@ const loadStats = async () => {
}
}
const loadSystemStatus = async () => {
try {
const response = await $fetch('/api/system/status')
systemStatus.value.online = response?.status === 'ok'
systemStatus.value.database = !!response?.database?.connected
systemStatus.value.api = response?.status === 'ok'
instanceId.value = response?.instance?.instanceId || ''
} catch (error) {
console.error('加载系统状态失败:', error)
systemStatus.value.online = false
systemStatus.value.database = false
systemStatus.value.api = false
instanceId.value = ''
}
}
const copyToClipboard = async (text) => {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return true
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', 'true')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand('copy')
document.body.removeChild(textarea)
return copied
}
const copyInstanceId = async () => {
if (!instanceId.value) {
showError(locale.value.noInstanceId || '暂无实例 ID')
return
}
try {
const copied = await copyToClipboard(instanceId.value)
if (copied) {
showSuccess(locale.value.copied || '已复制')
} else {
showError(locale.value.copyFailed || '复制失败')
}
} catch (error) {
console.error('复制实例 ID 失败:', error)
showError(locale.value.copyFailed || '复制失败')
}
}
const loadRecentActivities = async () => {
loadingActivities.value = true
try {
@@ -417,20 +339,13 @@ const refreshActivities = () => {
const formatTime = (dateString) => {
const date = new Date(dateString)
const now = getSyncedDate()
const now = new Date()
const diff = now - date
if (diff < 60000) return locale.value?.justNow || '刚刚'
if (diff < 3600000) {
const minutes = Math.floor(diff / 60000)
return formatLocaleValue(locale.value?.minutesAgo, `${minutes}分钟前`, minutes)
}
if (diff < 86400000) {
const hours = Math.floor(diff / 3600000)
return formatLocaleValue(locale.value?.hoursAgo, `${hours}小时前`, hours)
}
const days = Math.floor(diff / 86400000)
return formatLocaleValue(locale.value?.daysAgo, `${days}天前`, days)
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) => {
@@ -439,7 +354,6 @@ const navigateTo = (tab) => {
onMounted(() => {
loadStats()
loadSystemStatus()
loadRecentActivities()
})
</script>
@@ -452,10 +366,10 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
background: #3f3f46;
}
</style>
+113 -139
View File
@@ -3,49 +3,49 @@
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.desc }}
<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-bg-secondary-40 border border-border-secondary rounded-xl px-4 py-2 flex items-center gap-3"
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-text-tertiary uppercase tracking-widest"
>{{ locale.enableSelection }}</span
<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-primary-hover' : 'bg-bg-tertiary'
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
]"
@click="toggleGlobalEnabled"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all',
'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-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-lg shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
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" /> {{ locale.add }}
<Plus :size="14" /> 添加播出时段
</button>
</div>
</div>
<!-- 信息卡片 -->
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
<Info class="text-primary shrink-0 mt-0.5" :size="18" />
<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-text-secondary">{{ locale.aboutTitle }}</p>
<p class="text-[11px] text-text-tertiary leading-relaxed">
{{ locale.aboutDesc }}
<p class="text-xs font-bold text-zinc-300">关于时段限制</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
</p>
</div>
</div>
@@ -53,35 +53,37 @@
<!-- 加载状态 -->
<div
v-if="loading"
class="flex flex-col items-center justify-center py-20 space-y-4 bg-bg-secondary-20 border border-border-secondary-50 rounded-xl"
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
>
<AppSpinner :size="40" />
<p class="text-xs font-black text-text-tertiary uppercase tracking-widest">{{ locale.loading }}</p>
<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-error-5 border border-error-10 rounded-xl p-5 flex items-start gap-4"
class="bg-red-500/5 border border-red-500/10 rounded-xl p-5 flex items-start gap-4"
>
<AlertCircle class="text-error shrink-0 mt-0.5" :size="18" />
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-text-secondary">{{ locale.fetchFailed }}</p>
<p class="text-[11px] text-text-tertiary leading-relaxed">{{ error }}</p>
<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-bg-secondary-20 border border-border-secondary-50 rounded-xl"
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-bg-primary border border-border-secondary text-text-secondary">
<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-text-primary">{{ locale.emptyTitle }}</h3>
<p class="text-xs text-text-tertiary">{{ locale.emptyDesc }}</p>
<h3 class="text-lg font-bold text-zinc-100">暂无播出时段</h3>
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
</div>
</div>
@@ -92,15 +94,15 @@
v-for="playTime in playTimes"
:key="playTime.id"
:class="[
'group relative bg-bg-secondary-30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
playTime.enabled ? 'border-border-secondary-80' : 'border-border-secondary-40 opacity-60'
'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-bg-primary border border-border-secondary transition-all flex items-center justify-center',
playTime.enabled ? 'text-primary border-primary-20' : 'text-text-secondary'
'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" />
@@ -110,13 +112,13 @@
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
playTime.enabled
? 'bg-success-10 text-success border-success-20'
: 'bg-bg-tertiary-50 text-text-disabled border-border-tertiary-50'
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
]"
>
{{ playTime.enabled ? locale.enabled : locale.disabled }}
{{ playTime.enabled ? '已启用' : '已禁用' }}
</span>
<button class="p-1.5 text-text-secondary hover:text-text-tertiary">
<button class="p-1.5 text-zinc-700 hover:text-zinc-400">
<MoreVertical :size="14" />
</button>
</div>
@@ -125,34 +127,34 @@
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-text-primary group-hover:text-primary transition-colors"
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-primary-80 font-black tracking-tighter"
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
>
<span class="text-xl">{{ playTime.startTime || locale.unlimited }}</span>
<div class="w-4 h-[2px] bg-bg-tertiary" />
<span class="text-xl">{{ playTime.endTime || locale.unlimited }}</span>
<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-text-tertiary font-medium leading-relaxed min-h-[32px] line-clamp-2">
{{ playTime.description || locale.noDescription }}
<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-border-secondary-50 flex items-center justify-between">
<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-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-primary hover:border-primary-30 transition-all"
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-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-error hover:border-error-30 transition-all"
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" />
@@ -162,13 +164,13 @@
:class="[
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
playTime.enabled
? 'bg-bg-tertiary text-text-tertiary hover:text-text-primary'
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
? '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 ? locale.disable : locale.enable }}
{{ playTime.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
@@ -181,17 +183,17 @@
v-if="showAddForm || editingPlayTime"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="cancelForm" />
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
<div
class="relative w-full max-w-lg bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
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-text-primary tracking-tight">
{{ editingPlayTime ? locale.editTitle : locale.addTitle }}
<h3 class="text-xl font-black text-zinc-100 tracking-tight">
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
</h3>
<button
class="p-2 text-text-tertiary hover:text-text-primary transition-colors"
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
@click="cancelForm"
>
<X :size="20" />
@@ -200,64 +202,64 @@
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.name }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段名称</label
>
<input
v-model="formData.name"
type="text"
:placeholder="locale.namePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
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-text-disabled uppercase tracking-widest px-1"
>{{ locale.startTimeOptional }}</label
<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-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 appearance-none"
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-text-secondary pointer-events-none"
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-text-disabled px-1">{{ locale.startTimeHint }}</p>
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制开始时间</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.endTimeOptional }}</label
<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-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 appearance-none"
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-text-secondary pointer-events-none"
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-text-disabled px-1">{{ locale.endTimeHint }}</p>
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制结束时间</p>
</div>
</div>
<div class="space-y-2">
<label
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 flex items-center gap-2"
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 flex items-center gap-2"
>
<AlignLeft :size="10" /> {{ locale.descriptionOptional }}
<AlignLeft :size="10" /> 描述 (可选)
</label>
<textarea
v-model="formData.description"
:placeholder="locale.descriptionPlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 min-h-[100px] resize-none"
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>
@@ -265,36 +267,36 @@
<input
v-model="formData.enabled"
type="checkbox"
class="w-4 h-4 rounded border-border-secondary bg-bg-primary"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
>
<span
class="text-xs font-bold text-text-secondary group-hover:text-primary transition-colors"
>{{ locale.enableThisPlayTime }}</span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>启用此播出时段</span
>
</label>
<div
v-if="formError"
class="bg-error-10 border border-error-20 rounded-lg p-3 text-[11px] text-error"
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-bg-primary-50 border-t border-border-secondary flex justify-end gap-3">
<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-text-tertiary hover:text-text-secondary"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
@click="cancelForm"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="formSubmitting"
class="px-8 py-2.5 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
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 ? locale.saving : locale.saveSettings }}
{{ formSubmitting ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
@@ -305,40 +307,40 @@
<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-bg-primary-80 backdrop-blur-sm"
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
@click="showDeleteConfirm = false"
/>
<div
class="relative w-full max-w-sm bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
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-error-10 text-error flex items-center justify-center border border-error-10 shadow-xl shadow-[var(--error-glow-5)]"
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-text-primary">
{{ deleteConfirmTitleText }}
<h4 class="text-lg font-bold text-zinc-100">
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-text-tertiary leading-relaxed">
{{ locale.deleteConfirmDesc }}
<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-bg-primary border border-border-secondary text-text-tertiary text-xs font-black rounded-lg transition-all hover:bg-bg-tertiary"
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"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="deleteInProgress"
class="flex-1 px-4 py-3 bg-error hover:bg-error disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95"
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 ? locale.deleting : locale.confirmDelete }}
{{ deleteInProgress ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
@@ -350,12 +352,10 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { onMounted, reactive, ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
import type { PlayTime } from '~/types'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import type { PlayTime } from '~~/types'
import {
Plus,
Clock,
@@ -371,17 +371,6 @@ import {
const { getAuthConfig, isAdmin } = useAuth()
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => {
const base = admin.value?.playTimeManager || {}
const emptyText = () => ''
return useSafeLocale({
...base,
errors: { ...(base.errors || {}) },
messages: { ...(base.messages || {}) },
deleteConfirmTitle: base.deleteConfirmTitle || emptyText
})
})
const playTimes = ref<PlayTime[]>([])
const loading = ref(false)
@@ -394,12 +383,6 @@ const formSubmitting = ref(false)
const deleteInProgress = ref(false)
const formError = ref('')
const enablePlayTimeSelection = ref(false)
const deleteConfirmTitleText = computed(() => {
const title = locale.value.deleteConfirmTitle
const name = playTimeToDelete.value?.name || ''
if (typeof title === 'function') return title(name)
return (title || '').replace('{0}', name)
})
// 表单数据
const formData = reactive({
@@ -420,7 +403,7 @@ onMounted(async () => {
// 获取播出时段列表
const fetchPlayTimes = async () => {
if (!isAdmin.value) {
error.value = locale.value.errors.adminOnly
error.value = '只有管理员才能管理播出时段'
return
}
@@ -428,10 +411,11 @@ const fetchPlayTimes = async () => {
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
...authConfig
})
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
...authConfig,
query: { t: Date.now() }
})
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
@@ -463,7 +447,7 @@ const fetchPlayTimes = async () => {
return a.name.localeCompare(b.name)
})
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.fetchPlayTimesFailed
error.value = err.message || '获取播出时段失败'
} finally {
loading.value = false
}
@@ -480,7 +464,7 @@ const fetchSystemSettings = async () => {
})
enablePlayTimeSelection.value = data.enablePlayTimeSelection
} catch (err: any) {
console.error('获取系统设置失败:', getErrorMessage(err))
console.error('获取系统设置失败:', err.message)
}
}
@@ -504,9 +488,9 @@ const updateSystemSettings = async () => {
...authConfig
})
showNotification(locale.value.messages.systemSettingsUpdated, 'success')
showNotification('系统设置已更新', 'success')
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.updateSystemSettingsFailed
error.value = err.message || '更新系统设置失败'
showNotification(error.value, 'error')
// 如果失败,恢复状态
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
@@ -555,14 +539,9 @@ const togglePlayTimeStatus = async (playTime: PlayTime) => {
// 更新本地数据
await fetchPlayTimes()
showNotification(
playTime.enabled
? (locale.value?.messages?.playTimeDisabled || 'Play time disabled')
: (locale.value?.messages?.playTimeEnabled || 'Play time enabled'),
'success'
)
showNotification(playTime.enabled ? '播出时段已禁用' : '播出时段已启用', 'success')
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.updatePlayTimeStatusFailed
error.value = err.message || '更新播出时段状态失败'
showNotification(error.value, 'error')
}
}
@@ -590,9 +569,9 @@ const deletePlayTime = async () => {
await fetchPlayTimes()
showDeleteConfirm.value = false
playTimeToDelete.value = null
showNotification(locale.value?.messages?.playTimeDeleted || 'Play time deleted', 'success')
showNotification('播出时段已删除', 'success')
} catch (err: any) {
error.value = getErrorMessage(err) || locale.value.errors.deletePlayTimeFailed
error.value = err.message || '删除播出时段失败'
showNotification(error.value, 'error')
} finally {
deleteInProgress.value = false
@@ -605,13 +584,13 @@ const savePlayTime = async () => {
// 时间验证(仅当两个时间都有填写时才进行比较)
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
formError.value = locale.value.errors.startBeforeEnd
formError.value = '开始时间必须早于结束时间'
return
}
// 至少要有名称
if (!formData.name.trim()) {
formError.value = locale.value.errors.nameRequired
formError.value = '时段名称不能为空'
return
}
@@ -624,7 +603,7 @@ const savePlayTime = async () => {
)
if (nameExists) {
formError.value = locale.value.errors.nameExists
formError.value = '播出时段名称已存在,请使用其他名称'
return
}
@@ -648,14 +627,9 @@ const savePlayTime = async () => {
// 更新本地数据
await fetchPlayTimes()
cancelForm()
showNotification(
isUpdate
? (locale.value?.messages?.playTimeUpdated || 'Play time updated')
: (locale.value?.messages?.playTimeCreated || 'Play time created'),
'success'
)
showNotification(isUpdate ? '播出时段已更新' : '播出时段已创建', 'success')
} catch (err: any) {
formError.value = getErrorMessage(err) || locale.value.errors.savePlayTimeFailed
formError.value = err.message || '保存播出时段失败'
showNotification(formError.value, 'error')
} finally {
formSubmitting.value = false
@@ -0,0 +1,246 @@
<template>
<Transition name="fade">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click="$emit('close')"
>
<Transition name="scale">
<div
v-if="show"
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col"
@click.stop
>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">生成歌单卡片</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
@click="$emit('close')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
</button>
</div>
<div class="p-6 flex flex-col items-center gap-4">
<!-- 标题输入 -->
<div class="w-full space-y-2">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">歌单标题</label>
<input
v-model="cardTitle"
type="text"
placeholder="例如: 2026-03-21 校园午间广播"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-colors"
@input="drawCanvas"
>
</div>
<!-- Canvas 预览 -->
<div class="relative w-full aspect-[2/3] max-w-[280px] bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 shadow-inner flex items-center justify-center">
<canvas ref="canvasRef" width="400" :height="canvasHeight" class="w-full h-full object-contain bg-white"></canvas>
</div>
<p class="text-[10px] text-zinc-500 text-center">宽度 400 像素,高度自适应,适合在手机端查看和分享</p>
</div>
<div class="p-4 border-t border-zinc-800 bg-zinc-900/50 flex gap-3">
<button
class="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
@click="$emit('close')"
>
取消
</button>
<button
class="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider"
@click="downloadCard"
>
下载图片
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<script setup>
import { ref, onMounted, watch, nextTick, computed } from 'vue'
const props = defineProps({
show: Boolean,
songs: {
type: Array,
default: () => []
},
defaultTitle: {
type: String,
default: '校园广播歌单'
}
})
const emit = defineEmits(['close'])
const canvasRef = ref(null)
const cardTitle = ref(props.defaultTitle)
const aiPrompt = ref('anime style school campus sunset beautiful sky highly detailed')
const isGeneratingAi = ref(false)
const bgImageObj = ref(null)
const maxDisplaySongs = 35
const canvasHeight = computed(() => {
const count = Math.min(props.songs.length, maxDisplaySongs)
if (count <= 10) return 600
// 每多一首增加 40px 高度
return 600 + (count - 10) * 40
})
const drawCanvas = () => {
if (!canvasRef.value) return
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
if (!ctx) return
const height = canvasHeight.value
// 清空画布
if (bgImageObj.value) {
ctx.drawImage(bgImageObj.value, 0, 0, 400, height)
// 增加一层半透明黑色遮罩,确保文字和列表框可见
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'
ctx.fillRect(0, 0, 400, height)
} else {
ctx.fillStyle = '#F8FAFC' // Background color
ctx.fillRect(0, 0, 400, height)
}
// 绘制渐变头部
const gradient = ctx.createLinearGradient(0, 0, 400, 0)
if (bgImageObj.value) {
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.7)')
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.4)')
} else {
gradient.addColorStop(0, '#2563eb') // Primary Blue
gradient.addColorStop(1, '#3b82f6')
}
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 400, 100)
// 绘制标题
ctx.fillStyle = '#FFFFFF'
ctx.font = 'bold 24px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText(cardTitle.value || '校园广播歌单', 200, 50)
// 绘制副标题(日期)
ctx.font = '14px "Inter", "system-ui", sans-serif'
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fillText(new Date().toLocaleDateString('zh-CN'), 200, 80)
// 绘制列表背景
if (bgImageObj.value) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)' // 半透明白色背景
} else {
ctx.fillStyle = '#FFFFFF'
}
ctx.shadowColor = 'rgba(0,0,0,0.05)'
ctx.shadowBlur = 10
ctx.shadowOffsetY = 4
ctx.beginPath()
ctx.roundRect(20, 120, 360, height - 180, 12)
ctx.fill()
ctx.shadowColor = 'transparent'
// 绘制歌曲列表
ctx.textAlign = 'left'
let y = 160
const displaySongs = props.songs.slice(0, maxDisplaySongs) // 最多显示 35 首
displaySongs.forEach((schedule, index) => {
const song = schedule.song || schedule
// 序号
ctx.fillStyle = '#2563eb'
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
ctx.fillText(`${index + 1}.`, 40, y)
// 歌名
ctx.fillStyle = '#0F172A' // Neutral Dark
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
// 限制歌名长度
let title = song.title || '未知歌曲'
if (title.length > 12) title = title.substring(0, 12) + '...'
ctx.fillText(title, 70, y)
// 歌手
ctx.fillStyle = '#64748B'
ctx.font = '14px "Inter", "system-ui", sans-serif'
let artist = song.artist || '未知歌手'
if (artist.length > 10) artist = artist.substring(0, 10) + '...'
ctx.fillText(`- ${artist}`, 240, y)
y += 40
})
if (props.songs.length > maxDisplaySongs) {
ctx.fillStyle = '#94A3B8'
ctx.font = '14px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText(`...及其他 ${props.songs.length - maxDisplaySongs} 首歌曲`, 200, y + 10)
}
// 绘制底部水印
if (bgImageObj.value) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'
} else {
ctx.fillStyle = '#CBD5E1'
}
ctx.font = '12px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Generated by VoiceHub', 200, height - 20)
}
const downloadCard = () => {
if (!canvasRef.value) return
const dataUrl = canvasRef.value.toDataURL('image/png')
const link = document.createElement('a')
link.download = `歌单卡片_${new Date().toISOString().split('T')[0]}.png`
link.href = dataUrl
link.click()
if (window.$showNotification) {
window.$showNotification('图片导出成功', 'success')
}
}
watch(() => props.show, (newVal) => {
if (newVal) {
cardTitle.value = props.defaultTitle
nextTick(() => {
drawCanvas()
})
}
})
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.scale-enter-active,
.scale-leave-active {
transition: transform 0.2s ease, opacity 0.2s ease;
}
.scale-enter-from,
.scale-leave-to {
transform: scale(0.95);
opacity: 0;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<template>
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">图片歌单</h2>
<p class="text-sm text-zinc-500">选择日期生成广播排期卡片并分享</p>
</div>
<div class="flex flex-col gap-8">
<!-- 设置面板 -->
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
<Layout class="w-4 h-4 text-blue-500" /> 卡片设置
</h3>
<div class="flex flex-col gap-5 items-end">
<!-- 日期选择 -->
<div class="space-y-2 flex-1 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">排期日期</label>
<!-- 类似排期管理的日期选择器 -->
<AdminDatePicker
:modelValue="selectedDate"
@update:modelValue="selectedDate = $event"
@change="fetchSchedules"
/>
</div>
<button
:disabled="loading || schedules.length === 0"
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="showModal = true"
>
<ImageIcon class="w-4 h-4" />
{{ loading ? '加载中...' : (schedules.length === 0 ? '该日期无排期' : '生成卡片') }}
</button>
</div>
</div>
</div>
<!-- 预览区 -->
<div class="w-full flex flex-col gap-4">
<div v-if="schedules.length > 0" class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 shadow-lg">
<h3 class="text-lg font-bold text-zinc-100 mb-4">排期预览 ({{ schedules.length }} 首)</h3>
<div class="space-y-3">
<div v-for="(schedule, index) in schedules" :key="schedule.id" class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-lg">
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
<div class="flex-1 overflow-hidden">
<p class="text-sm text-zinc-200 font-medium truncate">{{ schedule.song?.title || schedule.title }}</p>
<p class="text-xs text-zinc-500 truncate">{{ schedule.song?.artist || schedule.artist }}</p>
</div>
</div>
</div>
</div>
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
<CalendarDays class="w-12 h-12 text-zinc-700 mb-4" />
<p class="text-zinc-500">该日期没有排期的歌曲</p>
</div>
</div>
</div>
<AdminPlaylistCardGenerator
:show="showModal"
:songs="schedules"
:default-title="`${selectedDate} 校园广播歌单`"
@close="showModal = false"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Layout, Image as ImageIcon, CalendarDays } from '@lucide/vue'
import AdminDatePicker from './AdminDatePicker.vue'
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const selectedDate = ref(new Date().toISOString().split('T')[0])
const schedules = ref([])
const loading = ref(false)
const showModal = ref(false)
const fetchSchedules = async () => {
loading.value = true
try {
const response = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: {
date: selectedDate.value,
includeDrafts: 'true'
}
})
if (response.success && response.data && response.data.schedules) {
schedules.value = response.data.schedules
} else {
schedules.value = []
}
} catch (error) {
console.error('Failed to fetch schedules:', error)
if (window.$showNotification) {
window.$showNotification('获取排期失败', 'error')
}
} finally {
loading.value = false
}
}
onMounted(() => {
fetchSchedules()
})
</script>
File diff suppressed because it is too large Load Diff
+293
View File
@@ -0,0 +1,293 @@
<template>
<div class="roles-manager">
<div class="admin-section-header">
<div>
<h2 class="text-xl font-black text-zinc-100 flex items-center gap-2">
<Shield class="w-6 h-6 text-indigo-400" />
权限组管理
</h2>
<p class="text-sm text-zinc-500 mt-1">管理各角色可以访问的后台页面功能</p>
</div>
<div class="flex items-center gap-3">
<button
class="px-4 py-2 bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 rounded-xl font-bold transition-all disabled:opacity-50 flex items-center gap-2"
:disabled="loading || saving"
@click="saveRoles"
>
<Save v-if="!saving" class="w-4 h-4" />
<Loader2 v-else class="w-4 h-4 animate-spin" />
{{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
<div v-if="loading" class="py-12 flex flex-col items-center justify-center text-zinc-500">
<Loader2 class="w-8 h-8 animate-spin mb-4" />
<p>正在加载权限配置...</p>
</div>
<div v-else-if="error" class="py-12 text-center text-red-400">
{{ error }}
<button class="mt-4 px-4 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700" @click="fetchRoles">
重试
</button>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
<div
v-for="(roleConfig, roleKey) in roles"
:key="roleKey"
class="role-card p-6 bg-zinc-900/50 border border-zinc-800 rounded-2xl"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-400">
<Shield class="w-5 h-5" />
</div>
<div>
<h3 class="font-bold text-zinc-200">{{ roleConfig.displayName }}</h3>
<p class="text-xs text-zinc-500 font-mono mt-0.5">{{ roleKey }}</p>
</div>
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input
v-model="roleConfig.canAccessAdmin"
type="checkbox"
class="hidden"
:disabled="roleKey === 'SUPER_ADMIN'"
/>
<div class="w-10 h-6 bg-zinc-800 rounded-full relative transition-colors" :class="{ 'bg-indigo-500': roleConfig.canAccessAdmin }">
<div class="absolute w-4 h-4 bg-white rounded-full top-1 transition-transform" :class="roleConfig.canAccessAdmin ? 'left-5' : 'left-1'" />
</div>
<span class="text-xs font-bold text-zinc-400">允许进后台</span>
</label>
</div>
<div v-if="roleConfig.canAccessAdmin" class="mt-6">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h4 class="text-xs font-bold text-zinc-500 uppercase tracking-wider">可访问页面</h4>
<span class="text-[10px] font-black px-2 py-0.5 rounded-md bg-zinc-800/60 text-zinc-400 border border-zinc-800">
已启用 {{ roleConfig.pages.length }} 项
</span>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-800/60 border border-zinc-800 text-zinc-300 hover:bg-zinc-800 transition-colors"
:disabled="roleKey === 'SUPER_ADMIN'"
@click="selectAll(roleKey)"
>
全选
</button>
<button
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-800/60 border border-zinc-800 text-zinc-300 hover:bg-zinc-800 transition-colors"
:disabled="roleKey === 'SUPER_ADMIN'"
@click="clearAll(roleKey)"
>
清空
</button>
</div>
</div>
<div class="space-y-5">
<div v-for="section in pageSections" :key="section.title" class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
{{ section.title }}
</div>
<button
class="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors"
:disabled="roleKey === 'SUPER_ADMIN'"
@click="toggleSection(roleKey, section)"
>
{{ isSectionAllSelected(roleKey, section) ? '取消全选' : '本组全选' }}
</button>
</div>
<div class="grid grid-cols-2 gap-2">
<label
v-for="page in section.pages"
:key="page.id"
class="flex items-center gap-2 p-2 rounded-xl border cursor-pointer transition-all"
:class="roleConfig.pages.includes(page.id) ? 'bg-indigo-500/10 border-indigo-500/30 text-indigo-200 shadow-[0_0_0_1px_rgba(99,102,241,0.15)]' : 'bg-zinc-800/30 border-zinc-800 text-zinc-500 hover:bg-zinc-800/70'"
>
<input
v-model="roleConfig.pages"
type="checkbox"
:value="page.id"
class="hidden"
:disabled="roleKey === 'SUPER_ADMIN' && isRequiredForSuperAdmin(page.id)"
/>
<component :is="page.icon" class="w-4 h-4" />
<span class="text-xs font-bold">{{ page.label }}</span>
</label>
</div>
</div>
</div>
</div>
<div v-else class="mt-6 py-8 text-center border border-dashed border-zinc-800 rounded-xl">
<p class="text-sm text-zinc-600">该角色不允许进入后台</p>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import {
Shield,
Save,
Loader2,
Calendar,
Music,
Users,
Bell,
Clock,
BookOpen,
Database,
Settings,
Ban,
Key,
BarChart,
FileEdit,
Mail
} from '@lucide/vue'
const roles = ref({})
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const pageSections = [
{
title: '数据中心',
pages: [
{ id: 'overview', label: '数据概览', icon: BarChart },
{ id: 'workflow-tutorial', label: '流程与教程', icon: BookOpen },
{ id: 'data-analysis', label: '数据分析', icon: BarChart }
]
},
{
title: '播音管理',
pages: [
{ id: 'songs', label: '歌曲管理', icon: Music },
{ id: 'schedule', label: '排期管理', icon: Calendar },
{ id: 'print', label: '打印排期', icon: BookOpen }
]
},
{
title: '业务设置',
pages: [
{ id: 'settings', label: '选曲日期', icon: Calendar },
{ id: 'request-times', label: '投稿管理', icon: FileEdit },
{ id: 'semesters', label: '学期管理', icon: BookOpen },
{ id: 'playtimes', label: '播出时段', icon: Clock }
]
},
{
title: '用户与权限',
pages: [
{ id: 'users', label: '用户管理', icon: Users },
{ id: 'roles', label: '权限组管理', icon: Shield },
{ id: 'blacklist', label: '黑名单管理', icon: Ban },
{ id: 'api-keys', label: 'API密钥管理', icon: Key }
]
},
{
title: '系统运维',
pages: [
{ id: 'site-config', label: '站点配置', icon: Settings },
{ id: 'notifications', label: '通知管理', icon: Bell },
{ id: 'smtp-config', label: '邮件配置', icon: Mail },
{ id: 'database', label: '数据库操作', icon: Database }
]
}
]
const allPages = pageSections.flatMap((s) => s.pages)
const isRequiredForSuperAdmin = (pageId) => {
return ['roles', 'users', 'site-config'].includes(pageId)
}
const selectAll = (roleKey) => {
const role = roles.value?.[roleKey]
if (!role || roleKey === 'SUPER_ADMIN') return
role.pages = Array.from(new Set(allPages.map((p) => p.id)))
}
const clearAll = (roleKey) => {
const role = roles.value?.[roleKey]
if (!role || roleKey === 'SUPER_ADMIN') return
role.pages = []
}
const isSectionAllSelected = (roleKey, section) => {
const role = roles.value?.[roleKey]
if (!role) return false
return section.pages.every((p) => role.pages.includes(p.id))
}
const toggleSection = (roleKey, section) => {
const role = roles.value?.[roleKey]
if (!role || roleKey === 'SUPER_ADMIN') return
const allSelected = section.pages.every((p) => role.pages.includes(p.id))
if (allSelected) {
role.pages = role.pages.filter((id) => !section.pages.some((p) => p.id === id))
return
}
role.pages = Array.from(new Set([...role.pages, ...section.pages.map((p) => p.id)]))
}
const fetchRoles = async () => {
loading.value = true
error.value = ''
try {
const data = await $fetch('/api/admin/roles')
roles.value = JSON.parse(JSON.stringify(data))
} catch (err) {
console.error('Failed to fetch roles:', err)
error.value = '加载权限配置失败'
} finally {
loading.value = false
}
}
const saveRoles = async () => {
saving.value = true
try {
await $fetch('/api/admin/roles', {
method: 'PUT',
body: roles.value
})
if (window.$showNotification) {
window.$showNotification('权限配置已保存', 'success')
}
} catch (err) {
console.error('Failed to save roles:', err)
if (window.$showNotification) {
window.$showNotification('保存失败: ' + (err.message || '未知错误'), 'error')
}
} finally {
saving.value = false
}
}
onMounted(() => {
fetchRoles()
})
</script>
<style scoped>
.role-card {
transition: all 0.3s ease;
}
.role-card:hover {
border-color: rgba(99, 102, 241, 0.3);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
</style>
+21 -31
View File
@@ -1,18 +1,18 @@
<template>
<div
class="backdrop-blur-md p-6 rounded-xl border border-primary-10 bg-bg-tertiary-70 shadow-2xl max-w-[400px] mx-auto text-text-primary transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_var(--modal-hover-shadow)]"
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-primary-10 text-text-primary font-bold text-lg">
{{ scheduleTitle }}
<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-text-primary" for="playDate">{{ locale.playDate }}</label>
<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-primary-10 rounded-lg text-base bg-bg-tertiary-50 text-text-primary outline-none transition-colors duration-150 focus:border-primary focus:shadow-[0_0_0_2px_var(--primary-light)]"
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"
>
@@ -23,19 +23,19 @@
<CustomSelect
v-model="playTimeId"
:options="playTimeOptions"
:label="locale.playTime"
:placeholder="locale.unspecified"
label="播出时段"
placeholder="未指定"
class-name="w-full"
/>
<div
v-if="song?.preferredPlayTime"
class="mt-3 p-3 bg-bg-secondary-5 rounded-lg text-sm flex items-start gap-2"
class="mt-3 p-3 bg-white/5 rounded-lg text-sm flex items-start gap-2"
>
<div class="text-base">💡</div>
<Icon class="text-base text-yellow-400" name="lightbulb" size="16" />
<div>
{{ locale.preferredPlayTime }}
<span class="font-medium text-info">
用户期望的播出时段:
<span class="font-medium text-indigo-300">
{{ song.preferredPlayTime.name }}
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
({{ formatPlayTimeRange(song.preferredPlayTime) }})
@@ -45,24 +45,24 @@
</div>
</div>
<div v-if="error" class="p-3 mt-4 bg-error-10 text-error rounded-lg">
<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-primary-10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-bg-secondary-10 text-text-primary hover:bg-bg-secondary-15"
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')"
>
{{ commonLocale.cancel }}
取消
</button>
<button
:disabled="loading"
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-info text-text-primary hover:bg-info hover:-translate-y-px disabled:bg-info-50 disabled:cursor-not-allowed disabled:transform-none"
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 ? locale.creating : locale.create }}
{{ loading ? '创建中...' : '创建排期' }}
</button>
</div>
</form>
@@ -73,7 +73,6 @@
import { onMounted, ref, computed } from 'vue'
import { useSongs } from '~/composables/useSongs'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
song: {
@@ -93,19 +92,10 @@ const playTimeId = ref('')
const error = ref('')
const playTimes = ref([])
const { playTimeEnabled } = useSongs()
const { common } = useLocale()
const commonLocale = computed(() => common.value || {})
const locale = computed(() => useSafeLocale(common.value?.scheduleForm || {}))
const scheduleTitle = computed(() => {
const title = locale.value?.title
if (typeof title === 'string') return title.replace(/{0}/g, props.song?.title || '')
if (typeof title === 'function') return title(props.song?.title || '')
return ''
})
// 转换播出时段为 CustomSelect 选项格式
const playTimeOptions = computed(() => {
const options = [{ label: locale.value.unspecified, value: '' }]
const options = [{ label: '未指定', value: '' }]
if (playTimes.value && playTimes.value.length > 0) {
playTimes.value.forEach((pt) => {
@@ -152,19 +142,19 @@ const formatPlayTimeRange = (playTime) => {
if (playTime.startTime && playTime.endTime) {
return `${playTime.startTime} - ${playTime.endTime}`
} else if (playTime.startTime) {
return formatLocale(locale.value.startAt, playTime.startTime) || playTime.startTime
return `${playTime.startTime} 开始`
} else if (playTime.endTime) {
return formatLocale(locale.value.endAt, playTime.endTime) || playTime.endTime
return `${playTime.endTime} 结束`
}
return locale.value.unlimited
return '不限时间'
}
const handleSubmit = () => {
error.value = ''
if (!playDate.value) {
error.value = locale.value.dateRequired
error.value = '请选择播放日期'
return
}
+83 -192
View File
@@ -1,79 +1,64 @@
<template>
<div class="schedule-item-print" :class="{ 'is-narrow': narrow }">
<div class="schedule-item-print">
<div class="item-content">
<div v-if="settings.showSequence || settings.showCover" class="item-leading">
<!-- 序号 -->
<div v-if="settings.showSequence" class="sequence-number">
{{ schedule.sequence || 1 }}
</div>
<!-- 序号 -->
<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"
:data-original-src="convertToHttps(schedule.song.cover)"
: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 v-if="settings.showCover" class="cover-section aspect-square">
<img
v-if="schedule.song.cover"
:alt="schedule.song.title"
:src="convertToHttps(schedule.song.cover)"
class="song-cover object-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div class="cover-placeholder object-contain" :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">
<span class="title-text">{{ schedule.song.title }}</span>
{{ schedule.song.title }}
<!-- 重播标识 -->
<span v-if="schedule.replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
<!-- 跨学期标识 -->
<span
v-if="
settings.currentSemester &&
schedule.song.semester &&
schedule.song.semester !== settings.currentSemester
"
class="cross-semester-badge-print"
>
{{ locale.crossSemester }}
</span>
<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 || settings.showVotes" class="item-trailing">
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
<div v-if="settings.showRequester" class="requester-info">
<span class="label">{{ locale.requesterPrefix }}</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>
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
<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>
</div>
<!-- 人数信息 -->
<div v-if="settings.showVotes" class="votes-info">
<span v-if="schedule.replayRequestId != null" class="label">{{ locale.replayRequestsPrefix }}</span>
<span v-else class="label">{{ locale.popularityPrefix }}</span>
<span class="value">{{
schedule.replayRequestId != null
? locale.replayCount(schedule.song.replayRequestCount)
: schedule.song.voteCount || 0
}}</span>
</div>
</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>
@@ -81,9 +66,6 @@
<script setup>
import { defineProps } from 'vue'
import { convertToHttps } from '~/utils/url'
import { useLocale } from '~/utils/locale'
const { admin } = useLocale()
const locale = computed(() => admin.value.schedulePrinter)
// 定义props
defineProps({
@@ -94,10 +76,6 @@ defineProps({
settings: {
type: Object,
required: true
},
narrow: {
type: Boolean,
default: false
}
})
@@ -113,9 +91,8 @@ const handleImageError = (event) => {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #d1d5db;
border-bottom: 1px solid #e5e5e5;
page-break-inside: avoid;
break-inside: avoid;
}
.item-content {
@@ -123,21 +100,6 @@ const handleImageError = (event) => {
align-items: center;
width: 100%;
gap: 12px;
min-width: 0;
}
.item-leading {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.item-trailing {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.sequence-number {
@@ -150,13 +112,14 @@ const handleImageError = (event) => {
justify-content: center;
font-weight: bold;
font-size: 14px;
color: #1a1a1a;
color: var(--text-quaternary, #333333);
flex-shrink: 0;
}
.cover-section {
width: 40px;
height: 40px;
aspect-ratio: 1 / 1;
flex-shrink: 0;
}
@@ -165,17 +128,20 @@ const handleImageError = (event) => {
height: 100%;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cover-placeholder {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
background: #f5f5f5;
border-radius: 4px;
display: none;
align-items: center;
justify-content: center;
color: #6c757d;
color: var(--text-tertiary, #999999);
}
.cover-placeholder.show {
@@ -195,53 +161,32 @@ const handleImageError = (event) => {
.song-title {
font-weight: bold;
font-size: 16px;
color: #1a1a1a;
color: var(--text-quaternary, #333333);
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
/* 歌名文本块:text-overflow 只能作用在块容器上,故单独承载裁剪 */
.title-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
/* 打印用重播标识 */
.replay-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f8f9fa;
border: 1px solid #0b5afe;
background: #e3f2fd;
border: 1px solid var(--border-secondary, #2196f3);
border-radius: 3px;
color: #2563eb;
color: #1976d2;
font-size: 10px;
font-weight: bold;
flex-shrink: 0;
}
/* 跨学期标识 */
.cross-semester-badge-print {
display: inline-block;
padding: 1px 4px;
background: #f5f5f5;
border: 1px solid #d1d5db;
color: #636366;
font-size: 10px;
border-radius: 2px;
font-weight: normal;
margin-left: 4px;
vertical-align: middle;
flex-shrink: 0;
}
.song-artist {
font-size: 14px;
color: #636366;
color: var(--text-quaternary, #666666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -252,91 +197,24 @@ const handleImageError = (event) => {
display: flex;
align-items: center;
font-size: 12px;
color: #636366;
color: var(--text-quaternary, #666666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.label {
font-weight: 500;
margin-right: 4px;
flex-shrink: 0;
}
.value {
color: #1a1a1a;
overflow: hidden;
text-overflow: ellipsis;
}
/* 两列窄版排布 */
.schedule-item-print.is-narrow {
padding: 6px 0;
}
.is-narrow .item-content,
.is-narrow .item-leading {
gap: 8px;
}
.is-narrow .sequence-number {
width: 22px;
height: 22px;
font-size: 12px;
}
.is-narrow .cover-section {
width: 32px;
height: 32px;
}
.is-narrow .cover-placeholder svg {
width: 16px;
height: 16px;
}
.is-narrow .song-title {
font-size: 13px;
gap: 3px;
}
.is-narrow .song-artist {
font-size: 11px;
}
.is-narrow .replay-badge-print,
.is-narrow .cross-semester-badge-print {
font-size: 9px;
padding: 0 3px;
}
/* 人数与歌名同行、投稿人与歌手同行 */
.is-narrow .item-trailing {
flex-direction: column;
align-items: flex-end;
gap: 0;
min-width: 0;
max-width: 45%;
}
.is-narrow .votes-info {
order: -1;
}
.is-narrow .requester-info,
.is-narrow .votes-info {
font-size: 11px;
justify-content: flex-end;
max-width: 100%;
color: var(--text-quaternary, #333333);
}
/* 打印样式 */
@media print {
.schedule-item-print {
color: #1a1a1a !important;
background: #ffffff !important;
color: var(--text-primary, #000) !important;
background: var(--card-bg, #fff) !important;
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
@@ -352,30 +230,43 @@ const handleImageError = (event) => {
.sequence-number {
background: #f0f0f0 !important;
color: #1a1a1a !important;
color: var(--text-primary, #000) !important;
width: 30px !important;
height: 30px !important;
flex-shrink: 0 !important;
}
.cover-section {
width: 40px !important;
height: 40px !important;
width: 48px !important;
height: 48px !important;
aspect-ratio: 1 / 1 !important;
flex-shrink: 0 !important;
}
.song-cover {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.cover-placeholder {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
border-radius: 0 !important;
}
.song-info {
flex: 1 !important;
min-width: 0 !important;
}
.song-title {
color: #1a1a1a !important;
color: var(--text-primary, #000) !important;
font-size: 16px !important;
white-space: nowrap !important;
}
.title-text {
overflow: hidden !important;
text-overflow: ellipsis !important;
}
@@ -383,11 +274,11 @@ const handleImageError = (event) => {
.song-artist,
.label,
.value {
color: #3a3a3c !important;
color: var(--text-quaternary, #333333) !important;
}
.time-range {
color: #636366 !important;
color: var(--text-quaternary, #666666) !important;
}
.requester-info,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+159 -232
View File
@@ -3,14 +3,14 @@
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.desc }}
<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-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
@click="openModal"
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"
@@ -26,7 +26,7 @@
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
{{ locale.add }}
添加新学期
</button>
</div>
@@ -44,24 +44,24 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-warning fill-warning"
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-text-disabled uppercase tracking-[0.2em]">
{{ locale.currentActive }}
<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-primary to-primary-hover rounded-3xl p-10 shadow-2xl shadow-[var(--primary-glow)] overflow-hidden group"
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-bg-secondary-10 backdrop-blur-md flex items-center justify-center text-text-primary border border-primary-20"
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"
@@ -79,10 +79,10 @@
</svg>
</div>
<div class="space-y-2">
<h4 class="text-3xl font-black text-text-primary tracking-tight leading-tight">
<h4 class="text-3xl font-black text-white tracking-tight leading-tight">
{{ currentSemester.name }}
</h4>
<div class="flex items-center gap-2 text-text-primary-80 font-bold text-xs">
<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"
@@ -99,20 +99,20 @@
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{{ getLocaleMessage('createdAt', formatDate(currentSemester.createdAt)) }}
创建于 {{ formatDate(currentSemester.createdAt) }}
</div>
</div>
<div class="pt-4">
<span
class="px-4 py-2 bg-bg-secondary-20 text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
class="px-4 py-2 bg-white text-blue-600 text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
>
{{ locale.inProgress }}
正在进行中
</span>
</div>
</div>
<!-- 装饰性背景元素 -->
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-bg-secondary-5 blur-3xl rounded-full" />
<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"
@@ -124,7 +124,7 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-primary"
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" />
@@ -134,7 +134,7 @@
<div
v-else
class="bg-bg-secondary-30 border border-border-secondary border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
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"
@@ -146,19 +146,19 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-secondary"
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-text-disabled">{{ locale.noCurrent }}</p>
<button class="text-xs text-primary font-black hover:underline" @click="openModal">
{{ locale.createNow }}
<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-bg-secondary-20 border border-border-secondary rounded-3xl flex items-start gap-4">
<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"
@@ -169,16 +169,16 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-tertiary shrink-0 mt-0.5"
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-text-tertiary">{{ locale.switchTitle }}</p>
<p class="text-[11px] text-text-disabled leading-relaxed">
{{ locale.switchDesc }}
<p class="text-xs font-bold text-zinc-400">学期切换说明</p>
<p class="text-[11px] text-zinc-600 leading-relaxed">
切换学期后,前端点歌界面将清空旧排期。所有历史数据会自动归档至对应学期下,您仍可在数据中心进行查看。
</p>
</div>
</div>
@@ -198,25 +198,25 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-text-disabled"
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-text-disabled uppercase tracking-[0.2em]">
{{ locale.historyTitle }}
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
学期历史记录
</h3>
</div>
<span class="text-[10px] font-black text-text-secondary">{{ getLocaleMessage('historyCount', semesters.length) }}</span>
<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-bg-secondary-10 border border-border-secondary border-dashed rounded-[2rem]"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-text-disabled">{{ locale.loading }}</p>
<p class="text-xs font-bold text-zinc-600">加载中...</p>
</div>
<div
@@ -225,8 +225,8 @@
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
:class="
sem.isActive
? 'bg-primary-hover-5 border-primary-30'
: 'bg-bg-secondary-40 border-border-secondary hover:border-border-tertiary'
? '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">
@@ -234,8 +234,8 @@
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
:class="
sem.isActive
? 'bg-primary-hover text-text-primary shadow-lg shadow-[var(--primary-glow)]'
: 'bg-bg-tertiary text-text-disabled border border-border-tertiary'
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
"
>
<svg
@@ -258,13 +258,13 @@
<h5
class="font-bold transition-colors"
:class="
sem.isActive ? 'text-primary' : 'text-text-primary group-hover:text-primary'
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
"
>
{{ sem.name }}
</h5>
<p class="text-[10px] text-text-disabled font-medium uppercase tracking-widest mt-0.5">
{{ getLocaleMessage('createdAt', formatDate(sem.createdAt)) }}
<p class="text-[10px] text-zinc-600 font-medium uppercase tracking-widest mt-0.5">
创建于 {{ formatDate(sem.createdAt) }}
</p>
</div>
</div>
@@ -272,7 +272,7 @@
<div class="flex items-center gap-2">
<div
v-if="sem.isActive"
class="flex items-center gap-2 px-4 py-2 bg-success-10 text-success rounded-xl border border-success-20"
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"
@@ -288,108 +288,83 @@
<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">{{ locale.currentSemester }}</span>
<span class="text-[10px] font-black uppercase tracking-widest">当前学期</span>
</div>
<button
v-else
:disabled="loading"
class="flex items-center gap-2 px-4 py-2 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-text-primary text-[10px] font-black rounded-xl border border-border-tertiary 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"
<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)"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
{{ locale.setCurrent }}
</button>
<button
:disabled="loading"
class="p-2.5 text-text-secondary hover:text-primary hover:bg-primary-10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:title="locale.editSemester"
@click="openEditModal(sem)"
>
<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"
<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)"
>
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
</svg>
</button>
<button
v-if="!sem.isActive"
:disabled="loading"
class="p-2.5 text-text-secondary hover:text-error hover:bg-error-10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:title="locale.deleteSemester"
@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>
<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-bg-secondary-10 border border-border-secondary border-dashed rounded-[2rem]"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-text-disabled">{{ locale.empty }}</p>
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
</div>
</div>
</div>
</div>
<!-- 添加/修改学期弹窗 -->
<!-- 添加学期弹窗 -->
<div
v-if="showModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-60 backdrop-blur-sm"
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-bg-secondary rounded-3xl border border-border-secondary shadow-2xl overflow-hidden"
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-text-primary tracking-tight">
{{ isEditing ? locale.editSemester : locale.add }}
</h3>
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
<button
class="p-2 text-text-tertiary hover:text-text-primary transition-colors"
@click="closeModal"
class="p-2 text-zinc-500 hover:text-white transition-colors"
@click="closeAddModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -410,35 +385,35 @@
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.semesterName }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>学期名称</label
>
<input
v-model="semesterForm.name"
v-model="newSemester.name"
type="text"
:placeholder="locale.namePlaceholder"
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 font-bold transition-all"
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 v-if="!isEditing" class="flex items-center gap-3 cursor-pointer group px-1">
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="semesterForm.isActive"
v-model="newSemester.isActive"
type="checkbox"
class="w-4.5 h-4.5 rounded-lg border-border-secondary bg-bg-primary transition-all"
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-text-secondary group-hover:text-primary transition-colors"
>{{ locale.setAsActive }}</span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>设为当前活跃学期</span
>
<p class="text-[10px] text-text-disabled font-medium">
{{ locale.setAsActiveHint }}
<p class="text-[10px] text-zinc-600 font-medium">
创建后将立即激活此学期,原学期将变为历史状态
</p>
</div>
</label>
<div class="p-4 bg-bg-primary border border-border-secondary rounded-2xl flex items-start gap-3">
<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"
@@ -449,32 +424,32 @@
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-warning shrink-0 mt-0.5"
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-text-tertiary leading-normal">
{{ locale.namingTip }}
<p class="text-[10px] text-zinc-500 leading-normal">
提示:请按照统一的命名规范设置学期名称,这有助于数据归档后的检索与分析。
</p>
</div>
</div>
</div>
<div class="p-6 bg-bg-tertiary-50 border-t border-border-secondary flex gap-3 justify-end">
<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-text-tertiary hover:text-text-secondary transition-colors"
@click="closeModal"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors"
@click="closeAddModal"
>
{{ locale.cancel }}
取消
</button>
<button
:disabled="submitting || !semesterForm.name.trim()"
class="px-8 py-2.5 bg-primary-hover hover:bg-primary disabled:bg-primary-hover disabled:opacity-50 text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
@click="handleSubmit"
: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 ? (isEditing ? locale.saving : locale.creating) : (isEditing ? locale.saveChanges : locale.createSemester) }}
{{ submitting ? '创建中...' : '创建学期' }}
</button>
</div>
</div>
@@ -484,10 +459,10 @@
<ConfirmDialog
:loading="loading"
:show="showDeleteDialog"
:cancel-text="locale.cancel"
:confirm-text="locale.confirmDelete"
cancel-text="取消"
confirm-text="确认删除"
:message="deleteMessage"
:title="locale.deleteDialogTitle"
title="确认删除学期"
type="danger"
@close="showDeleteDialog = false"
@confirm="confirmDelete"
@@ -499,12 +474,8 @@
import { onMounted, ref, computed } from 'vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.semesterManager || {})
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
const {
semesters,
@@ -515,20 +486,15 @@ const {
fetchCurrentSemester,
createSemester,
setActiveSemester,
updateSemester,
deleteSemester: deleteSemesterAPI
} = useSemesters()
const showModal = ref(false)
const isEditing = ref(false)
const editSemesterId = ref(null)
const originalSemesterName = ref('')
const showAddModal = ref(false)
const showDeleteDialog = ref(false)
const deleteTargetId = ref(null)
const deleteTargetName = ref('')
const submitting = ref(false)
const semesterForm = ref({
const newSemester = ref({
name: '',
isActive: false
})
@@ -542,7 +508,7 @@ const sortedSemesters = computed(() => {
// 删除确认信息
const deleteMessage = computed(() => {
return getLocaleMessage('deleteMessage', deleteTargetName.value)
return `确定要彻底删除 "${deleteTargetName.value}" 吗?此操作将同步清除该学期下的所有点歌记录、排期数据和统计信息。数据一旦删除将无法找回。`
})
// 格式化日期
@@ -563,13 +529,13 @@ const setActive = async (semesterId) => {
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
// 或者直接依据success返回值判断
if (success) {
showNotification(getNestedMessage('messages', 'activeSet'), 'success')
showNotification('活跃学期设置成功!', 'success')
} else {
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
showNotification(error.value || getNestedMessage('errors', 'setActiveFailedRetry'), 'error')
showNotification(error.value || '设置活跃学期失败,请稍后重试', 'error')
}
} catch (err) {
showNotification(err.message || getNestedMessage('errors', 'setFailed'), 'error')
showNotification(err.message || '设置失败', 'error')
}
}
@@ -590,12 +556,12 @@ const confirmDelete = async () => {
try {
const success = await deleteSemesterAPI(deleteTargetId.value)
if (success) {
showNotification(getNestedMessage('messages', 'deleted'), 'success')
showNotification('学期删除成功!', 'success')
} else {
showNotification(error.value || getNestedMessage('errors', 'deleteFailed'), 'error')
showNotification(error.value || '删除学期失败', 'error')
}
} catch (err) {
showNotification(err.message || getNestedMessage('errors', 'deleteFailedShort'), 'error')
showNotification(err.message || '删除失败', 'error')
}
showDeleteDialog.value = false
@@ -603,103 +569,64 @@ const confirmDelete = async () => {
deleteTargetName.value = ''
}
// 处理表单提交
const handleSubmit = async () => {
const normalizedName = semesterForm.value.name.trim()
if (!normalizedName) return
if (isEditing.value && normalizedName === originalSemesterName.value) {
showNotification(getNestedMessage('messages', 'nameUnchanged'), 'info')
closeModal()
return
}
// 处理添加学期
const handleAddSemester = async () => {
if (!newSemester.value.name.trim()) return
submitting.value = true
try {
if (isEditing.value && editSemesterId.value) {
// 修改学期
const success = await updateSemester(editSemesterId.value, {
name: normalizedName
})
if (success) {
showNotification(getNestedMessage('messages', 'updated'), 'success')
closeModal()
} else {
showNotification(error.value || getNestedMessage('errors', 'updateFailed'), 'error')
}
const result = await createSemester({
name: newSemester.value.name,
isActive: newSemester.value.isActive
})
if (result) {
showNotification('学期创建成功!', 'success')
closeAddModal()
} else {
// 创建学期
const result = await createSemester({
name: normalizedName,
isActive: semesterForm.value.isActive
})
if (result) {
showNotification(getNestedMessage('messages', 'created'), 'success')
closeModal()
} else {
showNotification(error.value || getNestedMessage('errors', 'createFailed'), 'error')
}
showNotification(error.value || '创建学期失败', 'error')
}
} catch (err) {
console.error(isEditing.value ? '修改学期失败:' : '创建学期失败:', err)
showNotification(
err.message || (isEditing.value ? getNestedMessage('errors', 'updateFailed') : getNestedMessage('errors', 'createFailed')),
'error'
)
console.error('创建学期失败:', err)
showNotification(err.message || '创建学期失败', 'error')
} finally {
submitting.value = false
}
}
// 关闭弹窗
const closeModal = () => {
showModal.value = false
isEditing.value = false
editSemesterId.value = null
originalSemesterName.value = ''
semesterForm.value = {
// 关闭添加弹窗
const closeAddModal = () => {
showAddModal.value = false
newSemester.value = {
name: '',
isActive: false
}
}
const getRecommendedName = () => {
const now = getSyncedDate()
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
let academicYear = year
let term = locale.value?.terms?.first || '第一学期'
let term = '第一学期'
if (month >= 3 && month <= 8) {
academicYear = year - 1
term = locale.value?.terms?.second || '第二学期'
term = '第二学期'
} else if (month <= 2) {
academicYear = year - 1
term = locale.value?.terms?.first || '第一学期'
term = '第一学期'
} else {
academicYear = year
term = locale.value?.terms?.first || '第一学期'
term = '第一学期'
}
return getLocaleMessage('recommendedName', academicYear, term)
return `${academicYear}学年${term}`
}
const openModal = () => {
isEditing.value = false
editSemesterId.value = null
semesterForm.value.name = getRecommendedName()
semesterForm.value.isActive = false
showModal.value = true
}
const openEditModal = (sem) => {
isEditing.value = true
editSemesterId.value = sem.id
originalSemesterName.value = sem.name
semesterForm.value.name = sem.name
semesterForm.value.isActive = sem.isActive
showModal.value = true
const openAddModal = () => {
newSemester.value.name = getRecommendedName()
showAddModal.value = true
}
// 组件挂载时获取数据
+92 -69
View File
@@ -2,7 +2,7 @@
<template>
<aside
:class="[
'fixed inset-y-0 left-0 z-50 w-64 bg-bg-primary border-r border-border-secondary transform transition-transform duration-300 ease-in-out lg:translate-x-0',
'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'
]"
>
@@ -12,26 +12,34 @@
<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="/assets/logo.png" alt="VoiceHub Logo" class="w-8 h-8 object-contain" >
<img
:src="logo"
alt="VoiceHub Logo"
class="w-8 h-8 object-contain"
width="32"
height="32"
decoding="async"
loading="lazy"
>
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-text-primary leading-none tracking-tight">VoiceHub</h1>
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
<p
class="text-[10px] text-text-tertiary mt-1.5 uppercase tracking-widest font-bold leading-none"
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
>
{{ locale.console }}
管理控制台
</p>
</div>
</NuxtLink>
</div>
<!-- 导航菜单区域 -->
<nav class="flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
<nav class="admin-sidebar-nav 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-text-disabled uppercase tracking-[0.2em] mb-2">
<h3 class="px-3 text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] mb-2">
{{ group.section }}
</h3>
<!-- 菜单项列表 -->
@@ -39,10 +47,11 @@
<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-primary-10 focus-visible:ring-inset',
'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-primary-hover-10 text-primary border-primary-20'
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-40 border-transparent'
? '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',
getTourClass(item.id)
]"
@click="onNavigate(item.id)"
>
@@ -52,15 +61,15 @@
:size="18"
:class="
activeTab === item.id
? 'text-primary'
: 'text-text-tertiary group-hover:text-text-secondary'
? '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-primary rounded-full shadow-[0_0_8px_var(--primary-60)]"
class="ml-auto w-1 h-1 bg-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
/>
</button>
</template>
@@ -69,38 +78,38 @@
</nav>
<!-- 用户信息及退出登录 -->
<div class="mt-4 pt-4 border-t border-border-secondary">
<div class="mt-4 pt-4 border-t border-zinc-800">
<div
class="flex items-center gap-3 p-3 rounded-lg bg-bg-secondary-50 border border-border-secondary-50 hover:bg-bg-tertiary-30 transition-colors"
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-border-tertiary shrink-0"
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-bg-tertiary flex items-center justify-center text-text-tertiary font-bold border border-border-tertiary shrink-0"
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 || locale.avatarFallback || '管').charAt(0) }}
{{ (currentUser?.name || '管').charAt(0) }}
</div>
<!-- 用户详细信息 -->
<div class="flex-1 min-w-0">
<p class="text-xs font-black truncate text-text-primary">
{{ currentUser?.name || locale.adminFallback }}
<p class="text-xs font-black truncate text-zinc-100">
{{ currentUser?.name || '管理员' }}
</p>
<p
class="text-[10px] text-text-tertiary truncate uppercase tracking-wider font-medium mt-0.5"
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
>
{{ getRoleDisplayName(currentUser?.role || 'ADMIN') }}
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
</p>
</div>
<!-- 退出按钮 -->
<button
class="p-2 text-text-disabled hover:text-error hover:bg-error-10 rounded-lg transition-all"
:title="locale.logout"
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" />
@@ -128,20 +137,20 @@ import {
LogOut,
Clock,
FileEdit,
ListChecks,
BookOpen,
Ban,
ListMusic,
Globe,
Image as ImageIcon,
ScrollText,
Database,
Lock,
Ticket,
GraduationCap
Shield
} from '@lucide/vue'
import { useLocale } from '~/utils/locale'
import logo from '~~/public/images/logo.png'
import { rolesConfig } from '~/composables/usePermissions'
const avatarError = ref(false)
const { admin } = useLocale()
const locale = computed(() => admin.value?.sidebar || {})
const props = defineProps({
// 侧边栏是否打开(移动端)
@@ -166,56 +175,57 @@ watch(
const emit = defineEmits(['navigate', 'close', 'logout'])
// 菜单分组配置
const menuGroups = computed(() => [
const menuGroups = [
{
section: locale.value.sections?.overview || '概览',
items: [{ icon: LayoutDashboard, label: locale.value.menu?.overview || '数据概览', id: 'overview' }]
},
{
section: locale.value.sections?.content || '内容管理',
section: '数据中心',
items: [
{ icon: CalendarDays, label: locale.value.menu?.schedule || '排班管理', id: 'schedule' },
{ icon: Printer, label: locale.value.menu?.print || '打印管理', id: 'print' },
{ icon: Music2, label: locale.value.menu?.songs || '点歌管理', id: 'songs' },
{
icon: BarChart3,
label: locale.value.menu?.dataAnalysis || '数据分析',
id: 'data-analysis',
permissionId: 'data-analysis'
}
{ icon: LayoutDashboard, label: '数据概览', id: 'overview' },
{ icon: BookOpen, label: '流程与教程', id: 'workflow-tutorial' },
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
]
},
{
section: locale.value.sections?.users || '用户管理',
section: '播音管理',
items: [
{ icon: Users, label: locale.value.menu?.users || '用户管理', id: 'users' },
{ icon: GraduationCap, label: locale.value.menu?.gradeClass || '年级班级', id: 'grade-class' }
{ icon: Music2, label: '歌曲管理', id: 'songs' },
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
{ icon: Printer, label: '打印排期', id: 'print' },
{ icon: ImageIcon, label: '图片歌单', id: 'playlist-card' }
]
},
{
section: locale.value.sections?.api || 'API',
items: [{ icon: Key, label: locale.value.menu?.apiKeys || 'API 密钥', id: 'api-keys' }]
},
{
section: locale.value.sections?.system || '系统设置',
section: '业务设置',
items: [
{ icon: Bell, label: locale.value.menu?.notifications || '通知管理', id: 'notifications' },
{ icon: Mail, label: locale.value.menu?.smtpConfig || 'SMTP 配置', id: 'smtp-config' },
{ icon: Clock, label: locale.value.menu?.playtimes || '播放时段', id: 'playtimes' },
{ icon: FileEdit, label: locale.value.menu?.requestTimes || '点歌时段', id: 'request-times' },
{ icon: BookOpen, label: locale.value.menu?.semesters || '学期管理', id: 'semesters' },
{ icon: Ban, label: locale.value.menu?.blacklist || '黑名单', id: 'blacklist' },
{ icon: Ticket, label: locale.value.menu?.cardCodes || '卡密管理', id: 'card-codes' },
{ icon: ListMusic, label: locale.value.menu?.musicSource || '音源控制', id: 'music-source' },
{ icon: Globe, label: locale.value.menu?.siteConfig || '站点配置', id: 'site-config' },
{ icon: Database, label: locale.value.menu?.database || '数据库', id: 'database' }
{ icon: CalendarDays, label: '选曲日期', id: 'expected-play-dates', permissionId: 'settings' },
{ icon: FileEdit, label: '投稿管理', id: 'request-times' },
{ icon: BookOpen, label: '学期管理', id: 'semesters' },
{ icon: Clock, label: '播出时段', id: 'playtimes' }
]
},
{
section: locale.value.sections?.account || '账户',
items: [{ icon: Lock, label: locale.value.menu?.password || '修改密码', id: 'password' }]
section: '用户与权限',
items: [
{ icon: Users, label: '用户管理', id: 'users' },
{ icon: Shield, label: '权限组管理', id: 'roles', permissionId: 'roles' },
{ icon: Ban, label: '黑名单管理', id: 'blacklist' },
{ icon: Key, label: 'API密钥管理', id: 'api-keys' }
]
},
{
section: '系统运维',
items: [
{ icon: Globe, label: '站点配置', id: 'site-config' },
{ icon: ScrollText, label: '更新日志', id: 'changelog' },
{ icon: Bell, label: '通知管理', id: 'notifications' },
{ icon: Mail, label: '邮件配置', id: 'smtp-config' },
{ icon: Database, label: '数据库操作', id: 'database' }
]
},
{
section: '账户管理',
items: [{ icon: Lock, label: '修改密码', id: 'password' }]
}
])
]
/**
* 判断是否应该显示该菜单组
@@ -238,12 +248,25 @@ const onNavigate = (id) => {
emit('navigate', id)
}
const getTourClass = (id) => `tour-nav-${id}`
/**
* 获取角色显示名称
* @param {string} role 角色标识
*/
const getRoleDisplayName = (role) => {
return locale.value.roles?.[role] || role
if (rolesConfig.value && rolesConfig.value[role]?.displayName) {
return rolesConfig.value[role].displayName
}
const roleNames = {
USER: '学生(普通用户)',
SONG_ADMIN: '工作人员',
VICE_MINISTER: '副部',
ADMIN: '部长',
RESIDENT: '常驻',
SUPER_ADMIN: '技术运维'
}
return roleNames[role] || role
}
</script>
@@ -256,10 +279,10 @@ const getRoleDisplayName = (role) => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--panel-bg-alt);
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: var(--panel-bg-hover);
background: #3f3f46;
}
</style>
File diff suppressed because it is too large Load Diff
+65 -135
View File
@@ -3,26 +3,26 @@
<!-- 头部区域 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
<p class="text-xs text-text-tertiary mt-1">
{{ locale.desc }}
<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-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-secondary text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
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 ? locale.reloading : locale.reload }}
{{ reloading ? '重载中...' : '重载SMTP' }}
</button>
<button
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
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 ? locale.saving : locale.save }}
<Save :size="14" /> {{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
@@ -31,20 +31,20 @@
<!-- 左侧栏:SMTP 设置与测试 -->
<div class="xl:col-span-4 space-y-8">
<!-- SMTP 核心设置 -->
<section class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-6">
<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-text-primary uppercase tracking-widest flex items-center gap-2"
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<Server :size="16" class="text-primary" /> {{ locale.serviceConfig }}
<Server :size="16" class="text-blue-500" /> 服务配置
</h3>
<button
class="relative w-10 h-5 rounded-full transition-colors"
:class="config.smtpEnabled ? 'bg-primary-hover' : 'bg-bg-tertiary'"
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-800'"
@click="config.smtpEnabled = !config.smtpEnabled"
>
<div
class="absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all"
class="absolute top-1 w-3 h-3 bg-white rounded-full transition-all"
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
/>
</button>
@@ -52,118 +52,118 @@
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.smtpHost }}</label
<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-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-text-disabled uppercase tracking-widest px-1"
>{{ locale.port }}</label
<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-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-text-disabled uppercase tracking-widest px-1"
>{{ locale.secure }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>加密方式</label
>
<CustomSelect
:model-value="config.smtpSecure ? secureOptionLabels.ssl : secureOptionLabels.none"
:options="[secureOptionLabels.ssl, secureOptionLabels.none]"
:model-value="config.smtpSecure ? 'SSL/TLS' : '无'"
:options="['SSL/TLS', '无']"
class="w-full"
@update:model-value="(val) => (config.smtpSecure = val === secureOptionLabels.ssl)"
@update:model-value="(val) => (config.smtpSecure = val === 'SSL/TLS')"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
>{{ locale.username }}</label
<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-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-text-disabled uppercase tracking-widest px-1"
>{{ locale.fromEmail }}</label
<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-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-text-tertiary px-1 italic">
{{ locale.fromEmailHint }}
<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-text-disabled uppercase tracking-widest px-1"
>{{ locale.password }}</label
<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-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-text-disabled uppercase tracking-widest px-1"
>{{ locale.fromName }}</label
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人姓名</label
>
<input
v-model="config.smtpFromName"
type="text"
:placeholder="locale.defaultFromName"
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-6">
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
<h3
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<Send :size="16" class="text-success" /> {{ locale.serviceTest }}
<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-text-disabled uppercase tracking-widest px-1"
>{{ locale.testEmail }}</label
<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="locale.testEmailPlaceholder"
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
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-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
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-success" />
<Check v-if="testResult?.success" :size="14" class="text-emerald-500" />
<Send v-else :size="14" />
{{ testing ? locale.sending : testResult?.success ? locale.sent : locale.send }}
{{ testing ? '发送中' : testResult?.success ? '已发送' : '发送' }}
</button>
</div>
</div>
@@ -173,8 +173,8 @@
class="flex items-center gap-2 p-3 rounded-xl text-xs"
:class="
testResult.success
? 'bg-success-10 text-success border border-success-20'
: 'bg-error-10 text-error border border-error-20'
? '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" />
@@ -200,26 +200,13 @@
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import { useLocale } from '~/utils/locale'
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'
const { showToast: showNotification } = useToast()
const { admin } = useLocale()
const locale = computed(() => admin.value?.smtpManager || {})
const getLocaleMessage = (key) => locale.value?.[key] || `SMTP 配置:${key}`
const getOptionText = (value, fallback) => {
if (typeof value === 'function') return value() || fallback
if (typeof value === 'string') return value || fallback
return fallback
}
const secureOptionLabels = computed(() => ({
ssl: getOptionText(locale.value?.secureOptions?.ssl, 'SSL/TLS'),
none: getOptionText(locale.value?.secureOptions?.none, 'None')
}))
// 响应式数据
const config = ref({
@@ -230,7 +217,7 @@ const config = ref({
smtpUsername: '',
smtpPassword: '',
smtpFromEmail: '',
smtpFromName: getLocaleMessage('defaultFromName')
smtpFromName: '校园广播站'
})
const testEmail = ref('')
@@ -240,30 +227,6 @@ const reloading = ref(false)
const testResult = ref(null)
const originalConfig = ref({})
// 将服务端返回的固定文案映射为当前语言,避免后端中文穿透到前端界面。
const getLocalizedServerMessage = (message) => {
if (!message) {
return ''
}
const serverMessages = locale.value?.serverMessages
if (!serverMessages) return message
const rawMessages = serverMessages.raw
if (rawMessages) {
const key = Object.keys(rawMessages).find((item) => rawMessages[item] === message)
return key ? serverMessages[key] : message
}
const matchedEntry = Object.values(serverMessages).find((entry) => entry?.raw === message)
return matchedEntry?.text || message
}
const localizeSmtpResult = (response) => ({
...response,
message: getLocalizedServerMessage(response?.message) || response?.message
})
// 加载配置
const loadConfig = async () => {
try {
@@ -277,14 +240,14 @@ const loadConfig = async () => {
smtpUsername: response.smtpUsername || '',
smtpPassword: response.smtpPassword || '',
smtpFromEmail: response.smtpFromEmail || '',
smtpFromName: response.smtpFromName || getLocaleMessage('defaultFromName')
smtpFromName: response.smtpFromName || '校园广播站'
}
// 保存原始配置用于重置
originalConfig.value = { ...config.value }
} catch (error) {
console.error('加载SMTP配置失败:', error)
showNotification(getLocaleMessage('loadFailed'), 'error')
showNotification('加载配置失败', 'error')
}
}
@@ -293,7 +256,7 @@ const saveConfig = async () => {
if (config.value.smtpEnabled) {
// 验证必填字段
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
showNotification(getLocaleMessage('incompleteConfig'), 'error')
showNotification('请填写完整的SMTP配置信息', 'error')
return
}
}
@@ -306,21 +269,17 @@ const saveConfig = async () => {
})
originalConfig.value = { ...config.value }
showNotification(getLocaleMessage('saveSuccess'), 'success')
showNotification('SMTP配置保存成功', 'success')
} catch (error) {
console.error('保存SMTP配置失败:', error)
showNotification(getLocalizedServerMessage(error.data?.message) || getLocaleMessage('saveFailed'), 'error')
showNotification(error.data?.message || '保存配置失败', 'error')
} finally {
saving.value = false
}
}
// 重置配置
const resetConfig = () => {
config.value = { ...originalConfig.value }
testResult.value = null
showNotification(getLocaleMessage('resetSuccess'), 'info')
}
const reloadSmtpConfig = async () => {
reloading.value = true
@@ -329,53 +288,24 @@ const reloadSmtpConfig = async () => {
method: 'POST'
})
if (!response.success) {
throw new Error(getLocalizedServerMessage(response.message) || getLocaleMessage('reloadFailed'))
throw new Error(response.message || 'SMTP重载失败')
}
showNotification(getLocalizedServerMessage(response.message) || getLocaleMessage('reloadSuccess'), 'success')
showNotification(response.message || 'SMTP配置已重载', 'success')
} catch (error) {
console.error('重载SMTP配置失败:', error)
showNotification(
getLocalizedServerMessage(error.data?.message || error.message) || getLocaleMessage('reloadFailed'),
'error'
)
showNotification(error.data?.message || error.message || '重载SMTP配置失败', 'error')
} finally {
reloading.value = false
}
}
// 测试连接
const testConnection = async () => {
if (!config.value.smtpEnabled || !config.value.smtpHost) {
showNotification(getLocaleMessage('serverRequired'), '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 = localizeSmtpResult(response)
} catch (error) {
console.error('测试连接失败:', error)
testResult.value = {
success: false,
message:
getLocalizedServerMessage(error.data?.message) || getLocaleMessage('testConnectionFailed')
}
} finally {
testing.value = false
}
}
// 发送测试邮件
const sendTestEmail = async () => {
if (!testEmail.value) {
showNotification(getLocaleMessage('testEmailRequired'), 'error')
showNotification('请输入测试邮箱地址', 'error')
return
}
@@ -391,9 +321,9 @@ const sendTestEmail = async () => {
}
})
testResult.value = localizeSmtpResult(response)
testResult.value = response
if (response.success) {
showNotification(getLocaleMessage('testEmailSuccess'), 'success')
showNotification('测试邮件发送成功', 'success')
setTimeout(() => {
testResult.value = null
}, 5000)
@@ -402,7 +332,7 @@ const sendTestEmail = async () => {
console.error('发送测试邮件失败:', error)
testResult.value = {
success: false,
message: getLocalizedServerMessage(error.data?.message) || getLocaleMessage('testEmailFailed')
message: error.data?.message || '发送测试邮件失败'
}
} finally {
testing.value = false
+204
View File
@@ -0,0 +1,204 @@
<template>
<div class="space-y-10 max-w-[1600px] mx-auto pb-20">
<div class="space-y-6">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
<div class="space-y-1">
<h2 class="text-3xl font-black text-zinc-100 tracking-tight">歌曲审核</h2>
<div class="flex items-center gap-6 mt-4">
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待审核</span>
<span class="text-sm font-bold text-amber-500">{{ pendingAuditSongs.length }}</span>
</div>
</div>
</div>
<div class="flex items-center gap-3">
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 text-xs font-bold rounded-lg border border-blue-500/20 transition-all disabled:opacity-50"
@click="refreshSongs(true)"
>
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
</button>
</div>
</div>
</div>
<!-- 歌曲表格 -->
<div class="bg-zinc-900/10 border border-zinc-800/40 rounded-xl overflow-hidden shadow-2xl relative">
<div v-if="loading" class="absolute inset-0 bg-zinc-950/40 backdrop-blur-sm z-10 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
</div>
</div>
<div class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-800/60 bg-zinc-900/40">
<div class="col-span-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest">歌曲信息</div>
<div class="col-span-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest">投稿人</div>
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest">期望排期</div>
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right pr-4">操作</div>
</div>
<div v-if="pendingAuditSongs.length === 0 && !loading" class="py-20 flex flex-col items-center justify-center text-zinc-500">
<Music :size="48" class="text-zinc-800 mb-4" />
<p class="text-sm font-bold">暂无待审核歌曲</p>
</div>
<div v-else class="divide-y divide-zinc-800/40">
<div
v-for="song in pendingAuditSongs"
:key="song.id"
class="grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center hover:bg-zinc-800/20 group relative"
>
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-800/40 border-zinc-700/30 cursor-pointer hover:opacity-80" @click.stop="playSong(song)">
<img v-if="song.cover" :src="convertToHttps(song.cover)" class="w-full h-full object-cover" referrerpolicy="no-referrer">
<Music v-else :size="20" class="text-zinc-600" />
</div>
<div class="min-w-0">
<h4 class="font-bold transition-colors flex items-center text-zinc-100 group-hover:text-blue-400">
<span class="truncate">{{ song.title }}</span>
<button v-if="song.hasSubmissionNote && song.submissionNote" class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all" title="查看备注留言" @click.stop="openSubmissionRemark(song)">
<MessageSquare :size="12" />
</button>
</h4>
<p class="text-xs text-zinc-500 font-medium truncate mt-0.5">{{ song.artist }}</p>
</div>
</div>
<div class="col-span-6 lg:col-span-2 flex flex-col lg:items-start">
<span class="text-xs font-bold text-zinc-400">{{ song.requester || '未知' }}</span>
<span v-if="song.user" class="text-[10px] font-bold text-zinc-600">@{{ song.user.username }}</span>
</div>
<div class="col-span-6 lg:col-span-3 flex flex-col">
<span v-if="song.expectedPlayDate" class="text-xs font-bold text-amber-500">
{{ song.expectedPlayDate }} 序号: {{ song.expectedPlaySequence }}
</span>
<span v-else class="text-xs font-bold text-zinc-500">未指定日期</span>
</div>
<div class="col-span-12 lg:col-span-3 flex items-center justify-end gap-2">
<button class="px-4 py-2 bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-500 hover:text-emerald-400 text-xs font-bold rounded-lg border border-emerald-500/20 transition-all" @click="auditSong(song.id, 'approved')">
通过
</button>
<button class="px-4 py-2 bg-red-600/10 hover:bg-red-600/20 text-red-500 hover:text-red-400 text-xs font-bold rounded-lg border border-red-500/20 transition-all" @click="rejectSong(song.id)">
未过审 (释放排期)
</button>
</div>
</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="showRejectDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<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="showRejectDialog = false"><X :size="20" /></button>
</div>
<div class="p-8 space-y-6">
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg">
<p class="text-xs text-zinc-400 mb-2">此操作会将歌曲标记为未过审,并<span class="text-red-400 font-bold">立即释放其占用的排期序号</span>,供其他同学选择。歌曲不会被删除。</p>
<textarea v-model="rejectReason" placeholder="请输入未过审原因 (可选),将通知投稿人..." class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-red-500/30 min-h-[100px] resize-none transition-all" />
</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="showRejectDialog = false">取消</button>
<button :disabled="auditLoading" class="px-8 py-2.5 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-lg transition-all disabled:opacity-50" @click="confirmReject">
{{ auditLoading ? '处理中...' : '确认释放排期' }}
</button>
</div>
</div>
</div>
</Transition>
<SubmissionRemarkDialog :show="submissionRemarkDialog.show" :song-title="submissionRemarkDialog.songTitle" :content="submissionRemarkDialog.content" :is-public="submissionRemarkDialog.isPublic" @close="submissionRemarkDialog.show = false" />
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import SubmissionRemarkDialog from '~/components/Admin/SubmissionRemarkDialog.vue'
import { RotateCcw, X, Music, MessageSquare } from '@lucide/vue'
import { useSongs } from '~/composables/useSongs'
import { useSongPlayer } from '~/composables/useSongPlayer'
import { useToast } from '~/composables/useToast'
import { convertToHttps } from '~/utils/url'
const { showToast } = useToast()
const { playSong } = useSongPlayer()
const loading = ref(false)
const auditLoading = ref(false)
const songs = ref([])
const showRejectDialog = ref(false)
const rejectReason = ref('')
const currentAuditId = ref(null)
const submissionRemarkDialog = ref({ show: false, songTitle: '', content: '', isPublic: false })
let songsService = null
const pendingAuditSongs = computed(() => {
return songs.value.filter(s =>
!s.played &&
!s.scheduled &&
(!s.submissionNote || (!s.submissionNote.includes('[审核未通过]') && !s.submissionNote.includes('[审核已通过]')))
)
})
const openSubmissionRemark = (song) => {
if (!song?.submissionNote) return
submissionRemarkDialog.value = {
show: true,
songTitle: `${song.title} - ${song.artist}`,
content: song.submissionNote,
isPublic: song.submissionNotePublic === true
}
}
const refreshSongs = async (bypassCache = false) => {
loading.value = true
try {
await songsService.fetchSongs(false, undefined, true, bypassCache)
songs.value = songsService.songs.value || []
} catch (error) {
console.error('刷新歌曲失败:', error)
} finally {
loading.value = false
}
}
const auditSong = async (id, status, reason = '') => {
try {
await $fetch('/api/admin/songs/audit', {
method: 'POST',
body: { songId: id, status, reason }
})
showToast(status === 'approved' ? '审核已通过' : '已驳回并释放排期', 'success')
await refreshSongs(true)
} catch (err) {
showToast('操作失败: ' + (err.data?.message || err.message), 'error')
}
}
const rejectSong = (id) => {
currentAuditId.value = id
rejectReason.value = ''
showRejectDialog.value = true
}
const confirmReject = async () => {
auditLoading.value = true
await auditSong(currentAuditId.value, 'rejected', rejectReason.value)
showRejectDialog.value = false
auditLoading.value = false
}
onMounted(async () => {
songsService = useSongs()
await refreshSongs()
})
</script>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+17 -78
View File
@@ -9,87 +9,38 @@
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
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-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
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-border-secondary-50 flex items-center justify-between">
<h3 class="text-xl font-black text-text-primary">{{ locale.title }}</h3>
<button class="text-text-tertiary hover:text-text-secondary transition-colors" @click="close">
<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-text-tertiary font-medium">{{ songTitle }}</p>
<p class="text-xs text-zinc-500 font-medium">{{ songTitle }}</p>
<span
v-if="noteStatus === 'pending'"
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border bg-warning-10 text-warning border-warning-20"
: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'
]"
>
{{ locale.statusPending }}
{{ isPublic ? '公开备注' : '仅管理员可见' }}
</span>
<span
v-else-if="noteStatus === 'rejected'"
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border bg-error-10 text-error border-error-20"
>
{{ locale.statusRejected }}
</span>
<label
v-else
for="is-public-checkbox"
class="flex items-center gap-2 cursor-pointer group"
:class="{ 'opacity-50': isUpdatingPublic }"
>
<input
id="is-public-checkbox"
type="checkbox"
:checked="isPublic"
:disabled="isUpdatingPublic"
@change="$emit('update:isPublic', $event.target.checked)"
class="w-4 h-4 rounded border-border-secondary bg-bg-primary cursor-pointer disabled:cursor-not-allowed"
/>
<span
:class="[
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border transition-colors',
isPublic
? 'bg-primary-10 text-primary border-primary-20 group-hover:bg-primary-20'
: 'bg-warning-10 text-warning border-warning-20 group-hover:bg-warning-20'
]"
>
{{ isPublic ? locale.publicRemark : locale.adminOnly }}
</span>
<span v-if="isUpdatingPublic" class="text-[10px] text-text-tertiary animate-pulse">{{ locale.updating }}</span>
</label>
</div>
<div class="bg-bg-primary-50 border border-border-secondary-50 rounded-xl p-4">
<p class="text-sm text-text-primary leading-relaxed whitespace-pre-wrap">
<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 v-if="noteStatus === 'pending' || noteStatus === 'rejected'" class="flex justify-end gap-2 pt-2">
<button
v-if="noteStatus === 'pending'"
type="button"
:disabled="isUpdatingPublic"
class="px-4 py-2 rounded-lg bg-error-10 text-error border border-error-20 hover:bg-error-20 transition-colors text-xs font-black uppercase tracking-widest disabled:opacity-40"
@click="$emit('reject')"
>
{{ locale.rejectButton }}
</button>
<button
type="button"
:disabled="isUpdatingPublic"
class="px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90 transition-opacity text-xs font-black uppercase tracking-widest disabled:opacity-40"
@click="$emit('approve')"
>
{{ locale.approveButton }}
</button>
</div>
</div>
</div>
</div>
@@ -97,9 +48,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { X } from '@lucide/vue'
import { useLocale } from '~/utils/locale'
defineProps({
show: {
@@ -117,22 +66,12 @@ defineProps({
isPublic: {
type: Boolean,
default: true
},
isUpdatingPublic: {
type: Boolean,
default: false
},
noteStatus: {
type: String,
default: null
}
})
const emit = defineEmits(['close', 'update:isPublic', 'approve', 'reject'])
const { admin } = useLocale()
const locale = computed(() => admin.value?.scheduleManager?.remarkDialog || {})
const emit = defineEmits(['close'])
const close = () => {
emit('close')
}
</script>
</script>
File diff suppressed because it is too large Load Diff
+86 -92
View File
@@ -13,7 +13,7 @@
@click="handleOverlayClick"
>
<!-- 遮罩层 -->
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<!-- 模态框面板 -->
<transition
@@ -26,58 +26,71 @@
>
<div
v-if="show"
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
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-border-secondary bg-bg-secondary"
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-text-primary">{{ locale.title }}</h3>
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
<button
class="p-2 text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors"
class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
@click="$emit('close')"
>
<Icon name="x" :size="20" />
<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-bg-secondary">
<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="locale.loading" spinner-type="circle" />
<LoadingState message="加载数据中..." spinner-type="circle" />
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="flex-1 flex flex-col items-center justify-center text-text-tertiary gap-4"
class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-4"
>
<div class="text-4xl">⚠️</div>
<Icon class="text-4xl text-yellow-500" name="alert-triangle" size="48" />
<p>{{ error }}</p>
<button
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary rounded-lg transition-colors text-sm font-medium"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm font-medium"
@click="retryFetch"
>
{{ commonLocale.retry }}
重试
</button>
</div>
<!-- 内容 -->
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
<!-- 用户信息头部 -->
<div class="px-6 py-4 bg-bg-secondary border-b border-border-secondary">
<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-text-primary">{{ userSongs.user.name }}</h4>
<span class="text-sm text-text-tertiary font-mono">{{
<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-text-tertiary"
class="text-sm text-zinc-400"
>
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
</p>
@@ -86,7 +99,7 @@
<!-- 工具栏 (标签页 + 过滤) -->
<div
class="flex flex-col sm:flex-row items-center justify-between border-b border-border-secondary bg-bg-secondary-50"
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">
@@ -96,8 +109,8 @@
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
:class="
activeTab === tab.id
? 'text-primary'
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-50'
? 'text-blue-500'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
"
@click="activeTab = tab.id"
>
@@ -106,8 +119,8 @@
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
:class="
activeTab === tab.id
? 'bg-primary-10 text-primary'
: 'bg-bg-tertiary text-text-tertiary'
? 'bg-blue-500/10 text-blue-500'
: 'bg-zinc-800 text-zinc-500'
"
>
{{ getTabCount(tab.id) }}
@@ -115,20 +128,20 @@
<!-- 激活指示器 -->
<div
v-if="activeTab === tab.id"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
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-border-secondary flex items-center gap-3 bg-bg-secondary-30"
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: locale.allSemesters, value: null }, ...semesterOptions]"
:label="locale.semesterFilter"
:placeholder="locale.allSemesters"
:options="[{ label: '全部学期', value: null }, ...semesterOptions]"
label="学期过滤"
placeholder="全部学期"
class-name="w-full sm:w-48"
/>
</div>
@@ -138,38 +151,38 @@
<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-text-tertiary"
class="flex flex-col items-center justify-center py-20 text-zinc-500"
>
<div class="text-5xl mb-4 opacity-50">{{ activeTabIcon }}</div>
<p>{{ locale.empty }}</p>
<Icon class="text-5xl mb-4 opacity-50" :name="activeTabIcon" size="48" />
<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-border-secondary bg-bg-tertiary-20 hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all duration-200"
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-text-primary truncate">{{ song.title }}</h5>
<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-bg-tertiary text-text-tertiary border border-border-tertiary"
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-text-tertiary truncate">{{ song.artist }}</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-text-tertiary"
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">{{ formatLocale(locale.votes, song.voteCount) }}</span>
<span v-if="song.voteCount !== undefined">{{ song.voteCount }} 票</span>
<span v-if="song.requestCount !== undefined"
>{{ formatLocale(locale.requests, song.requestCount) }}</span
>{{ song.requestCount }} 人申请</span
>
</div>
</div>
@@ -188,21 +201,21 @@
<!-- Meta Info (Desktop) -->
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
<span class="text-xs text-text-tertiary">{{ getMetaTime(song) }}</span>
<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-text-tertiary"
>{{ formatLocale(locale.votes, song.voteCount) }}</span
<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-text-tertiary"
>{{ formatLocale(locale.requests, song.requestCount) }}</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-text-secondary">{{ song.requester.name }}</div>
<div class="text-[10px] text-text-tertiary">
<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>
@@ -211,12 +224,12 @@
<!-- 投稿人信息 (移动端) -->
<div
v-if="song.requester"
class="sm:hidden pt-3 mt-1 border-t border-border-secondary-50 flex justify-between items-center text-xs"
class="sm:hidden pt-3 mt-1 border-t border-zinc-800/50 flex justify-between items-center text-xs"
>
<span class="text-text-tertiary">{{ locale.requester }}</span>
<span class="text-text-secondary">
<span class="text-zinc-500">投稿人:</span>
<span class="text-zinc-300">
{{ song.requester.name }}
<span class="text-text-tertiary ml-1">
<span class="text-zinc-500 ml-1">
{{ song.requester.grade }}{{ song.requester.class }}
</span>
</span>
@@ -235,9 +248,7 @@
import { ref, computed, watch } from 'vue'
import { useSemesters } from '~/composables/useSemesters'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Icon from '~/components/UI/Icon.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import { useLocale } from '~/utils/locale'
const props = defineProps({
show: {
@@ -261,16 +272,6 @@ const selectedSemester = ref(null)
// Composables
const { semesters, fetchSemesterOptions } = useSemesters()
const { common, currentLocale } = useLocale()
const commonLocale = computed(() => common.value || {})
const locale = computed(() => {
const base = common.value?.userSongsModal || {}
return {
...base,
tabs: { ...(base.tabs || {}) },
statuses: { ...(base.statuses || {}) }
}
})
// Computed
const semesterOptions = computed(() => {
@@ -280,22 +281,22 @@ const semesterOptions = computed(() => {
}))
})
const tabs = computed(() => [
{ id: 'submitted', label: locale.value.tabs.submitted },
{ id: 'voted', label: locale.value.tabs.voted },
{ id: 'replay', label: locale.value.tabs.replay }
])
const tabs = [
{ id: 'submitted', label: '投稿歌曲' },
{ id: 'voted', label: '投票歌曲' },
{ id: 'replay', label: '重播申请' }
]
const activeTabIcon = computed(() => {
switch (activeTab.value) {
case 'submitted':
return '🎵'
return 'music'
case 'voted':
return '❤️'
return 'heart'
case 'replay':
return '🔁'
return 'repeat'
default:
return '📄'
return 'file-text'
}
})
@@ -370,7 +371,7 @@ const fetchUserSongs = async () => {
userSongs.value = songsResponse
} catch (err) {
console.error('获取用户歌曲信息失败:', err)
error.value = err.data?.message || locale.value.fetchFailed
error.value = err.data?.message || '获取用户歌曲信息失败'
} finally {
loading.value = false
}
@@ -385,25 +386,18 @@ const handleOverlayClick = () => {
}
// Helpers
const formatTimeAgo = (key, value) => {
const message = commonLocale.value?.time?.[key]
if (typeof message === 'function') return message(value)
if (typeof message === 'string') return message.replace(/{(\d+)}/g, (match, index) => index === '0' ? String(value) : match)
return ''
}
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = getSyncedDate()
const now = new Date()
const diff = now - date
if (diff < 60000) return commonLocale.value?.time?.justNow || ''
if (diff < 3600000) return formatTimeAgo('minutesAgo', Math.floor(diff / 60000))
if (diff < 86400000) return formatTimeAgo('hoursAgo', Math.floor(diff / 3600000))
if (diff < 86400000 * 7) return formatTimeAgo('daysAgo', Math.floor(diff / 86400000))
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(currentLocale.value, {
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
@@ -412,25 +406,25 @@ const formatDate = (dateString) => {
const getMetaTime = (song) => {
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} ${locale.value.voted}`
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} ${locale.value.requested}`
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} 投票`
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} 申请`
return ''
}
const getStatusText = (song) => {
if (song.played) return locale.value.statuses.played
if (song.scheduled) return locale.value.statuses.scheduled
return locale.value.statuses.pending
if (song.played) return '已播放'
if (song.scheduled) return '已排期'
return '待排期'
}
const getStatusClasses = (song) => {
if (song.played) {
return 'bg-success-10 text-success border-success-20'
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
}
if (song.scheduled) {
return 'bg-primary-10 text-primary border-primary-20'
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
}
return 'bg-warning-10 text-warning border-warning-20'
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}
// 监听器
@@ -470,12 +464,12 @@ watch(
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--usersongs-scroll-thumb);
background-color: rgba(113, 113, 122, 0.3); /* zinc-500/30 */
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--usersongs-scroll-thumb-hover);
background-color: rgba(113, 113, 122, 0.5);
}
/* 标签页隐藏滚动条 */
+433 -193
View File
@@ -1,175 +1,101 @@
<template>
<Teleport to="body">
<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-[90] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
@click.self="closeModal"
>
<div
class="bg-bg-secondary border border-border-secondary w-full max-w-xl rounded-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
>
<!-- 头部 -->
<div
class="p-6 pb-4 flex items-center justify-between border-b border-border-secondary-50"
>
<div class="min-w-0">
<h3
class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3"
>
<div
class="w-10 h-10 rounded-xl bg-primary-hover-10 flex items-center justify-center text-primary shrink-0"
>
<Icon name="users" :size="20" />
</div>
{{ locale.title }}
</h3>
<p v-if="songInfo" class="text-xs text-text-tertiary mt-1 ml-13 truncate">
{{ songInfo.title }}
</p>
</div>
<button
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all shrink-0"
@click="closeModal"
>
<Icon name="x" :size="20" />
</button>
</div>
<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="flex-1 overflow-y-auto p-6 custom-scrollbar">
<!-- 歌曲信息 -->
<div
v-if="songInfo"
class="mb-5 p-5 bg-bg-primary-50 border border-border-secondary-50 rounded-xl"
>
<div class="flex items-start gap-4">
<div
class="w-11 h-11 rounded-xl bg-error-10 flex items-center justify-center text-error shrink-0"
>
<Icon name="music" :size="20" />
</div>
<div class="flex-1 min-w-0">
<h4 class="text-base font-black text-text-primary truncate">
{{ songInfo.title }}
</h4>
<p class="text-xs text-text-tertiary mt-1 truncate">{{ songInfo.artist }}</p>
</div>
<div
class="flex items-center gap-1.5 px-3 py-1.5 bg-error-10 border border-error-20 rounded-full text-error shrink-0"
>
<Icon name="heart" :size="12" class="fill-current" />
<span class="text-xs font-black">{{
formatLocale(locale.votes, totalVotes)
}}</span>
</div>
</div>
</div>
<!-- 加载状态 -->
<LoadingState v-if="loading" :message="locale.loading" spinner-type="circle" />
<!-- 错误状态 -->
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-16 text-center px-8"
>
<div
class="w-16 h-16 rounded-2xl bg-error-10 flex items-center justify-center text-error mb-4"
>
<Icon name="alert-triangle" :size="32" />
</div>
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
<button
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="fetchVoters"
>
{{ commonLocale.retry }}
</button>
</div>
<!-- 投票人员列表 -->
<div v-else-if="voters.length > 0">
<div class="flex items-center justify-between mb-3 px-1">
<div
class="flex items-center gap-2 text-[10px] font-black text-text-tertiary uppercase tracking-widest"
>
<Icon name="users" :size="12" class="text-primary" />
{{ formatLocale(locale.voters, voters.length) }}
</div>
<div class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
{{ formatLocale(locale.votes, totalVotes) }}
</div>
</div>
<div class="space-y-2 max-h-[45vh] overflow-y-auto custom-scrollbar pr-1">
<div
v-for="(voter, index) in voters"
:key="voter.id"
class="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:bg-bg-tertiary-50 hover:border-border-secondary transition-all"
>
<div
class="w-10 h-10 rounded-xl bg-bg-tertiary border border-border-secondary flex items-center justify-center text-text-secondary text-sm font-black shrink-0"
>
{{ getAvatarText(voter.name) }}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-black text-text-primary truncate">
{{ voter.name }}
</div>
<div
class="flex items-center gap-1.5 text-[10px] text-text-disabled mt-0.5 font-medium"
>
<Icon name="clock" :size="11" />
{{ formatVoteTime(voter.votedAt) }}
</div>
</div>
<div
class="text-[10px] font-black text-text-disabled px-2 py-1 bg-bg-tertiary rounded-lg shrink-0"
>
#{{ index + 1 }}
</div>
</div>
</div>
</div>
<!-- 无投票状态 -->
<div v-else class="flex flex-col items-center justify-center py-16 text-text-disabled">
<Icon name="heart" :size="48" class="mb-4 opacity-20" />
<p class="text-sm font-bold">{{ locale.empty }}</p>
</div>
</div>
<!-- 底部 -->
<div
class="p-6 border-t border-border-secondary-50 bg-bg-secondary-50 flex items-center justify-end"
>
<button
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="closeModal"
>
{{ commonLocale.close }}
</button>
<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>
</Transition>
</Teleport>
<div class="modal-footer">
<button class="close-button" @click="closeModal">关闭</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import { useLocale } from '~/utils/locale'
import { useServerErrors } from '~/composables/useLocaleText'
import { ref, watch } from 'vue'
// Props
const props = defineProps({
@@ -192,10 +118,6 @@ const error = ref('')
const songInfo = ref(null)
const voters = ref([])
const totalVotes = ref(0)
const { common, currentLocale } = useLocale()
const commonLocale = computed(() => common.value || {})
const locale = computed(() => common.value?.votersModal || {})
const { localize: localizeServerError } = useServerErrors()
// 方法
const closeModal = () => {
@@ -216,7 +138,7 @@ const fetchVoters = async () => {
totalVotes.value = response.totalVotes || 0
} catch (err) {
console.error('获取投票人员失败:', err)
error.value = localizeServerError(err, locale.value.fetchFailed)
error.value = err.data?.message || '获取投票人员失败'
} finally {
loading.value = false
}
@@ -229,25 +151,17 @@ const getAvatarText = (name) => {
return cleanName.slice(-1).toUpperCase()
}
const formatTimeAgo = (key, value) => {
const message = commonLocale.value?.time?.[key]
if (typeof message === 'function') return message(value)
if (typeof message === 'string')
return message.replace(/{(\d+)}/g, (match, index) => (index === '0' ? String(value) : match))
return ''
}
const formatVoteTime = (dateString) => {
const date = new Date(dateString)
const now = getSyncedDate()
const now = new Date()
const diff = now - date
if (diff < 60000) return commonLocale.value?.time?.justNow || ''
if (diff < 3600000) return formatTimeAgo('minutesAgo', Math.floor(diff / 60000))
if (diff < 86400000) return formatTimeAgo('hoursAgo', Math.floor(diff / 3600000))
if (diff < 604800000) return formatTimeAgo('daysAgo', Math.floor(diff / 86400000))
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(currentLocale.value, {
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
@@ -273,21 +187,347 @@ watch(
</script>
<style scoped>
/* 列表自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
.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: var(--surface-primary, var(--bg-primary, #1a1a1a));
border-radius: 16px;
border: 1px solid var(--border-primary, #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: var(--text-primary, #ffffff);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: var(--text-tertiary, #888888);
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: var(--bg-tertiary, #2a2a2a);
color: var(--text-primary, #ffffff);
}
.close-btn svg {
width: 20px;
height: 20px;
}
.modal-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.song-info {
background: var(--bg-tertiary, #2a2a2a);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid var(--border-secondary, #3a3a3a);
}
.song-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary, #ffffff);
margin: 0 0 8px 0;
}
.song-artist {
color: var(--text-tertiary, #888888);
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 var(--border-secondary, #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 var(--border-secondary, #3a3a3a);
}
.voters-title {
font-weight: 600;
color: var(--text-primary, #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: var(--bg-tertiary, #2a2a2a);
border-radius: 8px;
border: 1px solid var(--border-secondary, #3a3a3a);
transition: all 0.2s ease;
}
.voter-item:hover {
background: var(--bg-quaternary, #333333);
border-color: var(--border-secondary, #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: var(--text-primary, #ffffff);
font-weight: 500;
font-size: 14px;
}
.vote-time {
color: var(--text-tertiary, #888888);
font-size: 12px;
}
.voter-number {
color: var(--text-tertiary, #888888);
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: var(--text-quaternary, #666666);
}
.empty-state p {
color: var(--text-tertiary, #888888);
margin: 0;
}
.modal-footer {
padding: 0 24px 24px;
display: flex;
justify-content: flex-end;
}
.close-button {
background: var(--bg-quaternary, #3a3a3a);
color: var(--text-primary, #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;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--text-muted);
.voters-container::-webkit-scrollbar-track {
background: var(--bg-tertiary, #2a2a2a);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--text-muted);
.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>
+126
View File
@@ -0,0 +1,126 @@
<template>
<div class="space-y-6 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col gap-2">
<h2 class="text-2xl md:text-3xl font-bold tracking-tight text-zinc-100">工作流与使用教程</h2>
<p class="text-zinc-400">了解校园广播站点歌系统的标准操作流程和功能说明,帮助您更高效地管理点歌与排期。</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 歌曲管理流程 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-blue-400">
<CheckCircle2 class="w-6 h-6" />
<h3 class="text-xl font-semibold">1. 歌曲管理与审核</h3>
</div>
<ol class="space-y-4 text-zinc-300">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-zinc-100">查看所有歌曲</p>
<p class="text-sm text-zinc-500 mt-1">进入“歌曲管理”页面,系统会列出所有用户提交的歌曲。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-zinc-100">试听与检查</p>
<p class="text-sm text-zinc-500 mt-1">点击播放按钮试听歌曲质量,检查留言内容是否合规,确保没有不当言论。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">3</span>
<div>
<p class="font-medium text-zinc-100">拒绝或修改状态</p>
<p class="text-sm text-zinc-500 mt-1">如不符合要求,可以在歌曲管理中拒绝该歌曲,并填写退回原因;审核通过的歌曲可直接用于排期。</p>
</div>
</li>
</ol>
</div>
<!-- 排期流程 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-emerald-400">
<CalendarDays class="w-6 h-6" />
<h3 class="text-xl font-semibold">2. 节目排期流程</h3>
</div>
<ol class="space-y-4 text-zinc-300">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-zinc-100">选择日期与时段</p>
<p class="text-sm text-zinc-500 mt-1">在“排期管理”中,选择目标播放日期和播出时段。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-zinc-100">拖拽编排</p>
<p class="text-sm text-zinc-500 mt-1">将左侧“待排歌曲”拖拽至右侧的“播放列表”中,可上下拖拽调整播放顺序。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">3</span>
<div>
<p class="font-medium text-zinc-100">发布排期</p>
<p class="text-sm text-zinc-500 mt-1">排期完成后,点击“发布排期”。发布后用户即可在前端页面看到该日期的节目单。</p>
</div>
</li>
</ol>
</div>
<!-- 播音流程 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-purple-400">
<Mic2 class="w-6 h-6" />
<h3 class="text-xl font-semibold">3. 播音与打印</h3>
</div>
<ol class="space-y-4 text-zinc-300">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-zinc-100">生成歌单图片/打印</p>
<p class="text-sm text-zinc-500 mt-1">播音前,前往“图片歌单”生成今日节目单图片用于宣传,或在“打印排期”中生成播音员用的纸质稿。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-zinc-100">标记已播</p>
<p class="text-sm text-zinc-500 mt-1">播音结束后,系统可自动/手动将排期内的歌曲标记为“已播”,方便统计数据。</p>
</div>
</li>
</ol>
</div>
<!-- 常见问题 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-amber-400">
<HelpCircle class="w-6 h-6" />
<h3 class="text-xl font-semibold">常见问题 (FAQ)</h3>
</div>
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium text-zinc-200">Q: 为什么找不到某些待排歌曲?</h4>
<p class="text-sm text-zinc-500 mt-1">A: 请检查是否在排期页面的左侧选错了标签(如普通投稿/重播申请),或者该歌曲还未通过审核。</p>
</div>
<div>
<h4 class="text-sm font-medium text-zinc-200">Q: 什么是选曲日期?</h4>
<p class="text-sm text-zinc-500 mt-1">A: 选曲日期是用户在点歌时可选择的期望播放日期。请在“选曲日期”设置中提前开放可用的日期。</p>
</div>
<div>
<h4 class="text-sm font-medium text-zinc-200">Q: 权限不足怎么办?</h4>
<p class="text-sm text-zinc-500 mt-1">A: 不同的功能需要不同的管理员角色(如歌曲管理员、超级管理员),请联系超级管理员调整您的角色权限。</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { CheckCircle2, CalendarDays, Mic2, HelpCircle } from '@lucide/vue'
</script>
<style scoped>
/* 可以在此添加特定样式 */
</style>