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

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+314
View File
@@ -0,0 +1,314 @@
<script setup lang="ts">
/**
* 歌词渲染组件 - 基于 @applemusic-like-lyrics/core
*/
import {
type BaseRenderer,
LyricPlayer as CoreLyricPlayer,
type LyricLine,
type LyricLineMouseEvent,
type LyricPlayerBase,
type spring
} from '@applemusic-like-lyrics/core'
import {
type PropType,
type Ref,
type ShallowRef,
ref,
computed,
onMounted,
onUnmounted,
watch
} from 'vue'
// import '@applemusic-like-lyrics/core/style.css' // Disable static import for vite dynamic load compatibility
/**
* Props 定义
*/
const props = defineProps({
/**
* 是否禁用歌词播放组件
*/
disabled: {
type: Boolean,
default: false
},
/**
* 是否演出部分效果
*/
playing: {
type: Boolean,
default: true
},
/**
* 设置歌词行的对齐方式
*/
alignAnchor: {
type: String as PropType<'top' | 'bottom' | 'center'>,
default: 'center'
},
/**
* 设置默认的歌词行对齐位置
*/
alignPosition: {
type: Number,
default: 0.5
},
/**
* 设置是否使用物理弹簧算法
*/
enableSpring: {
type: Boolean,
default: true
},
/**
* 设置是否启用歌词行的模糊效果
*/
enableBlur: {
type: Boolean,
default: true
},
/**
* 设置是否启用缩放效果
*/
enableScale: {
type: Boolean,
default: true
},
/**
* 设置是否隐藏已经播放过的歌词行
*/
hidePassedLines: {
type: Boolean,
default: false
},
/**
* 设置当前播放歌词
*/
lyricLines: {
type: Object as PropType<LyricLine[]>,
required: false
},
/**
* 设置当前播放进度
*/
currentTime: {
type: Number,
default: 0
},
/**
* 设置文字动画的渐变宽度
*/
wordFadeWidth: {
type: Number,
default: 0.5
},
/**
* X 轴弹簧参数
*/
linePosXSpringParams: {
type: Object as PropType<Partial<spring.SpringParams>>,
required: false
},
/**
* Y 轴弹簧参数
*/
linePosYSpringParams: {
type: Object as PropType<Partial<spring.SpringParams>>,
required: false
},
/**
* 缩放弹簧参数
*/
lineScaleSpringParams: {
type: Object as PropType<Partial<spring.SpringParams>>,
required: false
},
/**
* 渲染器类
*/
lyricPlayer: {
type: Object as PropType<{
new (...args: ConstructorParameters<typeof BaseRenderer>): BaseRenderer
}>,
required: false
}
})
/**
* 歌词播放组件的事件
*/
const emit = defineEmits<{
lineClick: [event: LyricLineMouseEvent]
lineContextmenu: [event: LyricLineMouseEvent]
}>()
/**
* 歌词播放组件的引用
*/
export interface LyricPlayerRef {
/**
* 歌词播放实例
*/
lyricPlayer: Ref<LyricPlayerBase | undefined>
/**
* 将歌词播放实例的元素包裹起来的 DIV 元素实例
*/
wrapperEl: Readonly<ShallowRef<HTMLDivElement | null>>
}
// 模板引用
const wrapperRef = ref<HTMLDivElement | null>(null)
// 歌词播放实例
const playerRef = ref<CoreLyricPlayer>()
// 事件处理器
const lineClickHandler = (e: Event) => emit('lineClick', e as LyricLineMouseEvent)
const lineContextMenuHandler = (e: Event) => emit('lineContextmenu', e as LyricLineMouseEvent)
// 底部行元素 (用于 Teleport)
const bottomLineEl = computed(() => playerRef.value?.getBottomLineElement())
// 组件挂载时初始化
onMounted(() => {
const wrapper = wrapperRef.value
if (wrapper) {
playerRef.value = new CoreLyricPlayer()
wrapper.appendChild(playerRef.value.getElement())
playerRef.value.addEventListener('line-click', lineClickHandler)
playerRef.value.addEventListener('line-contextmenu', lineContextMenuHandler)
}
})
// 组件卸载时清理
onUnmounted(() => {
const player = playerRef.value
if (player) {
player.removeEventListener('line-click', lineClickHandler)
player.removeEventListener('line-contextmenu', lineContextMenuHandler)
player.dispose()
}
})
// 动画帧更新
watchEffect((onCleanup) => {
if (!props.disabled) {
let canceled = false
let lastTime = -1
const onFrame = (time: number) => {
if (canceled) return
if (lastTime !== -1) {
playerRef.value?.update(time - lastTime)
}
lastTime = time
requestAnimationFrame(onFrame)
}
requestAnimationFrame(onFrame)
onCleanup(() => {
canceled = true
})
}
})
// 播放/暂停状态
watchEffect(() => {
if (props.playing !== undefined) {
if (props.playing) {
playerRef.value?.resume()
} else {
playerRef.value?.pause()
}
} else playerRef.value?.resume()
})
// 对齐锚点
watchEffect(() => {
if (props.alignAnchor !== undefined) playerRef.value?.setAlignAnchor(props.alignAnchor)
})
// 隐藏已播放歌词行
watchEffect(() => {
if (props.hidePassedLines !== undefined)
playerRef.value?.setHidePassedLines(props.hidePassedLines)
})
// 对齐位置
watchEffect(() => {
if (props.alignPosition !== undefined) playerRef.value?.setAlignPosition(props.alignPosition)
})
// 弹簧动画
watchEffect(() => {
playerRef.value?.setEnableSpring(props.enableSpring)
})
// 模糊效果
watchEffect(() => {
playerRef.value?.setEnableBlur(props.enableBlur)
})
// 缩放效果
watchEffect(() => {
playerRef.value?.setEnableScale(props.enableScale)
})
// 歌词行数据
watchEffect(() => {
if (props.lyricLines !== undefined) playerRef.value?.setLyricLines(props.lyricLines)
})
// 当前播放时间
watch(
() => props.currentTime,
(time, oldTime) => {
if (time === undefined) return
const isSeek = oldTime !== undefined && Math.abs(time - oldTime) > 1000
playerRef.value?.setCurrentTime(time, isSeek)
},
{ immediate: true }
)
// 渐变宽度
watchEffect(() => {
if (props.wordFadeWidth !== undefined) playerRef.value?.setWordFadeWidth(props.wordFadeWidth)
})
// X 轴弹簧参数
watchEffect(() => {
if (props.linePosXSpringParams !== undefined)
playerRef.value?.setLinePosXSpringParams(props.linePosXSpringParams)
})
// Y 轴弹簧参数
watchEffect(() => {
if (props.linePosYSpringParams !== undefined)
playerRef.value?.setLinePosYSpringParams(props.linePosYSpringParams)
})
// 缩放弹簧参数
watchEffect(() => {
if (props.lineScaleSpringParams !== undefined)
playerRef.value?.setLineScaleSpringParams(props.lineScaleSpringParams)
})
// 暴露给父组件
defineExpose<LyricPlayerRef>({
lyricPlayer: playerRef as unknown as Ref<LyricPlayerBase | undefined>,
wrapperEl: wrapperRef as unknown as Readonly<ShallowRef<HTMLDivElement | null>>
})
</script>
<template>
<div ref="wrapperRef" class="amll-wrapper">
<Teleport v-if="bottomLineEl" :to="bottomLineEl">
<slot name="bottom-line" />
</Teleport>
</div>
</template>
<style scoped>
.amll-wrapper {
width: 100%;
height: 100%;
}
</style>
+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
File diff suppressed because it is too large Load Diff
+985
View File
@@ -0,0 +1,985 @@
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
@click="$emit('close')"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
@click.stop
>
<!-- 头部 -->
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-xl bg-purple-600/10 flex items-center justify-center text-purple-500"
>
<Layers :size="20" />
</div>
批量更新学生信息
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">
快速调整学生年级、班级或通过 Excel 批量修改
</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-xl transition-all"
@click="$emit('close')"
>
<X :size="20" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-8 pt-6 custom-scrollbar space-y-8">
<!-- 更新方式选择 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'grade-only'
? 'bg-purple-500/5 border-purple-500/50 ring-4 ring-purple-500/10'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
>
<input v-model="updateType" type="radio" value="grade-only" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'grade-only'
? 'bg-purple-500 text-white'
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
]"
>
<Calendar :size="18" />
</div>
<div
v-if="updateType === 'grade-only'"
class="w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center"
>
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
>仅更新年级</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
>批量更新选中学生的年级,保持班级不变</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'excel-batch'
? 'bg-emerald-500/5 border-emerald-500/50 ring-4 ring-emerald-500/10'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
>
<input v-model="updateType" type="radio" value="excel-batch" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'excel-batch'
? 'bg-emerald-500 text-white'
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
]"
>
<FileSpreadsheet :size="18" />
</div>
<div
v-if="updateType === 'excel-batch'"
class="w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center"
>
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
>Excel 批量更新</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
>通过 Excel 文件精确匹配并批量修改学生信息</span
>
</label>
<label
:class="[
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
updateType === 'status-batch'
? 'bg-amber-500/5 border-amber-500/50 ring-4 ring-amber-500/10'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
>
<input v-model="updateType" type="radio" value="status-batch" class="sr-only" >
<div class="flex items-center justify-between mb-3">
<div
:class="[
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
updateType === 'status-batch'
? 'bg-amber-500 text-white'
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
]"
>
<ShieldAlert :size="18" />
</div>
<div
v-if="updateType === 'status-batch'"
class="w-5 h-5 rounded-full bg-amber-500 flex items-center justify-center"
>
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
>设置账户状态</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
>批量设置选中学生的账户状态</span
>
</label>
</div>
<!-- 学生选择面板 (年级更新和状态更新共用) -->
<div
v-if="['grade-only', 'status-batch'].includes(updateType)"
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<div class="p-6 bg-zinc-950/50 border border-zinc-800/50 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
>
<Filter :size="14" class="text-purple-500" />
学生范围筛选
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>当前年级</label
>
<CustomSelect
v-model="gradeFilter"
:options="gradeOptions"
label-key="label"
value-key="value"
placeholder="全部年级"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>当前班级</label
>
<CustomSelect
v-model="classFilter"
:options="classOptions"
label-key="label"
value-key="value"
placeholder="全部班级"
class-name="w-full"
/>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between ml-1">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>选择学生 ({{ selectedUserIds.length }}/{{ filteredStudents.length }})</label
>
<button
class="text-[10px] font-black text-purple-400 hover:text-purple-300 uppercase tracking-widest transition-colors"
@click="toggleSelectAll"
>
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
</button>
</div>
<div
class="max-h-48 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 p-2 custom-scrollbar"
>
<div
v-if="filteredStudents.length === 0"
class="py-10 text-center text-xs text-zinc-600 font-medium"
>
没有匹配条件的学生
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-1">
<label
v-for="student in filteredStudents"
:key="student.id"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-900/50 cursor-pointer transition-colors group"
>
<input
v-model="selectedUserIds"
:value="student.id"
type="checkbox"
class="w-4 h-4 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
>
<div class="flex flex-col">
<span
class="text-xs font-bold text-zinc-200 group-hover:text-purple-400 transition-colors"
>{{ student.name }}</span
>
<span class="text-[10px] text-zinc-600 font-mono">{{
student.username
}}</span>
</div>
</label>
</div>
</div>
</div>
</div>
<!-- 目标年级设置 -->
<div v-if="updateType === 'grade-only'" class="p-6 bg-purple-500/5 border border-purple-500/20 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-purple-400 uppercase tracking-widest"
>
<Save :size="14" />
目标设置
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>目标年级</label
>
<CustomSelect
v-model="targetGrade"
:options="targetGradeOptions"
label-key="label"
value-key="value"
placeholder="请选择目标年级"
class-name="w-full"
/>
</div>
<label
class="flex items-center gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-700 transition-all"
>
<input
v-model="keepClass"
type="checkbox"
class="w-5 h-5 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
>
<span class="text-xs font-bold text-zinc-300">保持原有班级不变</span>
</label>
</div>
</div>
<!-- 目标状态设置 -->
<div v-if="updateType === 'status-batch'" class="p-6 bg-amber-500/5 border border-amber-500/20 rounded-xl space-y-6">
<div class="flex items-center gap-2 text-xs font-black text-amber-400 uppercase tracking-widest">
<Save :size="14" />
目标状态设置
</div>
<div class="grid grid-cols-1 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">选择账户状态</label>
<CustomSelect
v-model="targetStatus"
:options="statusOptions"
label-key="label"
value-key="value"
placeholder="请选择目标状态"
class-name="w-full"
/>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">变更原因说明</label>
<div class="relative group">
<MessageSquare class="absolute left-4 top-3 text-zinc-700 group-focus-within:text-amber-500 transition-colors" :size="16" />
<textarea
v-model="statusReason"
rows="2"
placeholder="例如: 2025届学生统一毕业"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200 resize-none"
></textarea>
</div>
</div>
</div>
</div>
</div>
<!-- Excel 批量更新面板 -->
<div
v-if="updateType === 'excel-batch'"
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<!-- 文件上传区 -->
<div
:class="[
'relative group cursor-pointer transition-all',
isDragOver ? 'scale-[0.99]' : ''
]"
@drop="handleDrop"
@dragover.prevent
@dragenter.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@click="$refs.fileInput.click()"
>
<input
ref="fileInput"
accept=".xlsx,.xls"
class="hidden"
type="file"
@change="handleFileSelect"
>
<div
:class="[
'w-full py-12 border-2 border-dashed rounded-xl transition-all flex flex-col items-center justify-center gap-4',
isDragOver
? 'border-emerald-500 bg-emerald-500/10'
: 'border-zinc-800 hover:border-emerald-500/50 hover:bg-emerald-500/5'
]"
>
<div
:class="[
'w-16 h-16 rounded-lg bg-zinc-900 flex items-center justify-center transition-colors shadow-xl',
isDragOver ? 'text-emerald-500' : 'text-zinc-600 group-hover:text-emerald-500'
]"
>
<Upload :size="32" />
</div>
<div class="text-center">
<p class="text-base font-black text-zinc-200 tracking-tight">
拖拽 Excel 文件到此处
</p>
<p class="text-xs text-zinc-500 mt-1">
或 <span class="text-emerald-500 font-bold">点击选择文件</span> (支持 .xlsx /
.xls)
</p>
</div>
</div>
</div>
<!-- 模板与说明 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
class="md:col-span-2 p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3"
>
<div
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>
<Info :size="12" />
文件格式规范
</div>
<ul class="text-[10px] text-zinc-600 space-y-1.5 font-medium leading-relaxed">
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
第一行为表头:用户名、姓名、年级、班级、新用户名
</li>
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
<span class="text-zinc-400 font-bold">用户名</span> 列用于精确匹配现有账户
</li>
<li class="flex items-start gap-2">
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
字段留空则保持原值不变,不进行覆盖更新
</li>
</ul>
</div>
<button
class="p-5 bg-emerald-500/5 border border-emerald-500/20 hover:border-emerald-500/40 rounded-xl transition-all flex flex-col items-center justify-center gap-2 group"
@click="downloadTemplate"
>
<div
class="w-10 h-10 rounded-lg bg-emerald-500 text-white flex items-center justify-center shadow-lg shadow-emerald-900/20 group-hover:scale-110 transition-transform"
>
<Download :size="20" />
</div>
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
>获取模板文件</span
>
</button>
</div>
<!-- 预览表格 -->
<div v-if="excelPreviewData.length > 0" class="space-y-4">
<div class="flex items-center justify-between ml-1">
<label class="text-xs font-black text-zinc-400 uppercase tracking-widest"
>数据预览 ({{ excelPreviewData.length }}条)</label
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500" />
<span class="text-[10px] text-zinc-500 font-bold">待更新</span>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-500" />
<span class="text-[10px] text-zinc-500 font-bold">错误</span>
</div>
</div>
</div>
<div class="rounded-xl border border-zinc-800 bg-zinc-950 overflow-hidden shadow-xl">
<div class="overflow-x-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead
class="bg-zinc-900/80 text-[10px] font-black text-zinc-500 uppercase tracking-widest border-b border-zinc-800"
>
<tr>
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
<th class="px-5 py-4 whitespace-nowrap">当前信息</th>
<th class="px-5 py-4 whitespace-nowrap">更新后</th>
<th class="px-5 py-4 whitespace-nowrap text-right">匹配状态</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-900">
<tr
v-for="(row, index) in excelPreviewData.slice(0, 10)"
:key="index"
:class="[
row.error ? 'bg-red-500/5' : 'hover:bg-zinc-900/30 transition-colors'
]"
>
<td class="px-5 py-4">
<div class="flex flex-col">
<span
:class="[
'text-xs font-bold',
row.error ? 'text-red-400' : 'text-zinc-200'
]"
>{{ row.username }}</span
>
<span class="text-[10px] text-zinc-600 font-medium">{{
row.name || '-'
}}</span>
</div>
</td>
<td class="px-5 py-4">
<div class="text-[10px] text-zinc-500 font-medium">
{{ row.currentGrade || '-' }}年级 {{ row.currentClass || '-' }}
</div>
</td>
<td class="px-5 py-4">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-emerald-400">{{
row.newGrade || row.currentGrade || '-'
}}</span>
<span class="text-[10px] text-zinc-700">/</span>
<span class="text-xs font-bold text-emerald-400">{{
row.newClass || row.currentClass || '-'
}}</span>
</div>
</td>
<td class="px-5 py-4 text-right">
<span
v-if="row.error"
class="px-2 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-black rounded uppercase tracking-tighter border border-red-500/20"
>
{{ row.error }}
</span>
<span
v-else
class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 text-[10px] font-black rounded uppercase tracking-tighter border border-emerald-500/20"
>
已就绪
</span>
</td>
</tr>
</tbody>
</table>
</div>
<div
v-if="excelPreviewData.length > 10"
class="p-4 text-center border-t border-zinc-900 bg-zinc-900/20 text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
>
以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
</div>
</div>
</div>
</div>
<!-- 错误提示 -->
<div
v-if="error"
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs animate-in shake duration-300"
>
<AlertCircle :size="16" />
{{ error }}
</div>
</div>
<!-- 底部按钮 -->
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50 flex gap-3">
<button
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="$emit('close')"
>
取消操作
</button>
<button
:disabled="loading || !canUpdate"
:class="[
'flex-[2] px-6 py-4 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg active:scale-95',
updateType === 'excel-batch'
? 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-900/20'
: updateType === 'status-batch'
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-900/20'
: 'bg-purple-600 hover:bg-purple-500 shadow-purple-900/20'
]"
@click="performUpdate"
>
<RefreshCw v-if="loading" class="animate-spin" :size="16" />
<Save v-else :size="16" />
{{ loading ? '正在提交更新...' : '确认并开始更新' }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useAuth } from '~/composables/useAuth'
import {
Layers,
X,
Calendar,
FileSpreadsheet,
Check,
Filter,
ChevronDown,
Save,
Upload,
Download,
Info,
AlertCircle,
RefreshCw,
CheckCircle2,
ShieldAlert,
MessageSquare
} from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const props = defineProps({
show: Boolean,
users: Array
})
const emit = defineEmits(['close', 'update-success'])
// 响应式数据
const updateType = ref('grade-only')
const loading = ref(false)
const error = ref('')
// 仅更新年级相关
const gradeFilter = ref('')
const classFilter = ref('')
const selectedUserIds = ref([])
const targetGrade = ref('')
const keepClass = ref(true)
// 状态批量更新相关
const targetStatus = ref('')
const statusReason = ref('')
const statusOptions = [
{ label: '正常访问', value: 'active' },
{ label: '限制访问 (毕业生)', value: 'graduate' },
{ label: '限制访问 (退学)', value: 'withdrawn' }
]
// Excel批量更新相关
const isDragOver = ref(false)
const excelPreviewData = ref([])
const fileInput = ref(null)
// 所有用户的年级班级信息
const allGrades = ref([])
const allClasses = ref([])
// 所有学生用户数据
const allStudents = ref([])
// 服务
const auth = useAuth()
// 计算属性
const students = computed(() => {
return allStudents.value.length > 0
? allStudents.value
: props.users.filter((user) => user.role === 'USER')
})
const availableGrades = computed(() => {
return allGrades.value.length > 0
? allGrades.value
: [...new Set(students.value.map((s) => s.grade).filter(Boolean))].sort()
})
const availableClasses = computed(() => {
return allClasses.value.length > 0
? allClasses.value
: [...new Set(students.value.map((s) => s.class).filter(Boolean))].sort()
})
const targetGradeOptions = computed(() => {
return availableGrades.value.map((g) => ({ label: g, value: g }))
})
const gradeOptions = computed(() => {
return [
{ label: '全部年级', value: '' },
...availableGrades.value.map((g) => ({ label: g, value: g }))
]
})
const classOptions = computed(() => {
return [
{ label: '全部班级', value: '' },
...availableClasses.value.map((c) => ({ label: c, value: c }))
]
})
const filteredStudents = computed(() => {
let filtered = students.value
if (gradeFilter.value) {
filtered = filtered.filter((s) => s.grade === gradeFilter.value)
}
if (classFilter.value) {
filtered = filtered.filter((s) => s.class === classFilter.value)
}
return filtered
})
const isAllSelected = computed(() => {
return (
filteredStudents.value.length > 0 &&
selectedUserIds.value.length === filteredStudents.value.length
)
})
const canUpdate = computed(() => {
if (updateType.value === 'grade-only') {
return selectedUserIds.value.length > 0 && targetGrade.value.trim()
} else if (updateType.value === 'excel-batch') {
return excelPreviewData.value.length > 0 && excelPreviewData.value.some((row) => !row.error)
} else if (updateType.value === 'status-batch') {
return selectedUserIds.value.length > 0 && targetStatus.value && statusReason.value.trim()
}
return false
})
// 方法
const toggleSelectAll = () => {
if (isAllSelected.value) {
selectedUserIds.value = []
} else {
selectedUserIds.value = filteredStudents.value.map((s) => s.id)
}
}
const handleFileSelect = (event) => {
const file = event.target.files[0]
if (file) {
processExcelFile(file)
}
}
const handleDrop = (event) => {
event.preventDefault()
isDragOver.value = false
const file = event.dataTransfer.files[0]
if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls'))) {
processExcelFile(file)
}
}
const processExcelFile = async (file) => {
try {
loading.value = true
error.value = ''
// 确保学生数据已加载
if (students.value.length === 0) {
console.log('学生数据为空,重新获取数据...')
await fetchAllStudents()
// 等待一小段时间确保数据更新
await new Promise((resolve) => setTimeout(resolve, 100))
}
// 动态加载XLSX库
if (typeof window.XLSX === 'undefined') {
await loadXLSX()
}
const reader = new FileReader()
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target.result)
const workbook = window.XLSX.read(data, { type: 'array' })
const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]
const jsonData = window.XLSX.utils.sheet_to_json(worksheet)
parseExcelData(jsonData)
} catch (parseError) {
console.error('解析Excel文件失败:', parseError)
error.value = 'Excel文件格式错误,请检查文件格式'
loading.value = false
}
}
reader.onerror = () => {
console.error('读取文件失败')
error.value = '读取文件失败,请重试'
loading.value = false
}
reader.readAsArrayBuffer(file)
} catch (err) {
console.error('处理Excel文件失败:', err)
const errorMessage = err && err.message ? err.message : '未知错误,请检查文件格式或网络连接'
error.value = '处理Excel文件失败: ' + errorMessage
loading.value = false
}
}
const parseExcelData = (jsonData) => {
const previewData = []
const userMap = new Map()
// 创建用户映射,同时处理用户名标准化
students.value.forEach((user) => {
if (user.username) {
const normalizedUsername = user.username.trim().toLowerCase()
userMap.set(normalizedUsername, user)
userMap.set(user.username, user)
}
})
jsonData.forEach((row, index) => {
const username = (row['用户名'] || row['username'] || '').toString().trim()
const name = row['姓名'] || row['name'] || ''
const newGrade = row['年级'] || row['grade'] ? String(row['年级'] || row['grade']).trim() : ''
const newClass = row['班级'] || row['class'] ? String(row['班级'] || row['class']).trim() : ''
const newUsername = row['新用户名'] || row['new_username'] || ''
if (!username) {
previewData.push({
username: '',
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: '用户名不能为空'
})
return
}
const existingUser =
userMap.get(username) ||
userMap.get(username.toLowerCase()) ||
userMap.get(username.toUpperCase())
if (!existingUser) {
previewData.push({
username: username,
name: name,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername,
error: '用户不存在'
})
return
}
previewData.push({
userId: existingUser.id,
username: username,
name: existingUser.name,
currentGrade: existingUser.grade,
currentClass: existingUser.class,
newGrade: newGrade,
newClass: newClass,
newUsername: newUsername
})
})
excelPreviewData.value = previewData
loading.value = false
}
const loadXLSX = async () => {
return new Promise((resolve, reject) => {
if (typeof window.XLSX !== 'undefined') {
resolve()
return
}
const script = document.createElement('script')
script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'
script.onload = resolve
script.onerror = (error) => {
console.error('加载XLSX库失败:', error)
reject(new Error('无法加载Excel处理库,请检查网络连接'))
}
document.head.appendChild(script)
})
}
const downloadTemplate = () => {
const templateData = [
{ 用户名: 'student001', 姓名: '张三', 年级: '2025', 班级: '1班', 新用户名: 'new_student001' },
{ 用户名: 'student002', 姓名: '李四', 年级: '2025', 班级: '2班', 新用户名: '' }
]
const ws = window.XLSX.utils.json_to_sheet(templateData)
const wb = window.XLSX.utils.book_new()
window.XLSX.utils.book_append_sheet(wb, ws, '学生信息')
window.XLSX.writeFile(wb, '学生信息批量更新模板.xlsx')
}
const performUpdate = async () => {
try {
loading.value = true
error.value = ''
if (updateType.value === 'grade-only') {
await performGradeUpdate()
} else if (updateType.value === 'excel-batch') {
await performExcelUpdate()
} else if (updateType.value === 'status-batch') {
await performStatusUpdate()
}
emit('update-success')
emit('close')
} catch (err) {
console.error('批量更新失败:', err)
error.value = '批量更新失败: ' + err.message
} finally {
loading.value = false
}
}
const performGradeUpdate = async () => {
const response = await $fetch('/api/admin/users/batch-grade-update', {
method: 'POST',
body: {
userIds: selectedUserIds.value,
targetGrade: targetGrade.value.trim(),
keepClass: keepClass.value
},
...auth.getAuthConfig()
})
if (!response.success) {
throw new Error(response.message || '批量更新失败')
}
}
const performExcelUpdate = async () => {
const validUpdates = excelPreviewData.value.filter((row) => !row.error && row.userId)
if (validUpdates.length === 0) {
throw new Error('没有有效的更新数据')
}
// 分批更新,避免请求过大
const batchSize = 50
for (let i = 0; i < validUpdates.length; i += batchSize) {
const batch = validUpdates.slice(i, i + batchSize)
const updates = batch.map((row) => ({
userId: row.userId,
grade: row.newGrade ? String(row.newGrade).trim() : undefined,
class: row.newClass ? String(row.newClass).trim() : undefined,
username: row.newUsername ? String(row.newUsername).trim() : undefined
}))
await $fetch('/api/admin/users/batch-update', {
method: 'POST',
body: { updates },
...auth.getAuthConfig()
})
}
}
const performStatusUpdate = async () => {
const response = await $fetch('/api/admin/users/batch-status', {
method: 'PUT',
body: {
userIds: selectedUserIds.value,
status: targetStatus.value,
reason: statusReason.value.trim()
},
...auth.getAuthConfig()
})
if (!response.success) {
throw new Error(response.message || '批量更新状态失败')
}
}
// 获取所有学生用户数据
const fetchAllStudents = async () => {
try {
const response = await $fetch('/api/admin/users', {
method: 'GET',
query: {
page: 1,
limit: 10000,
role: 'USER'
},
...auth.getAuthConfig()
})
if (response.success && response.users) {
const users = response.users
allStudents.value = users
const grades = [...new Set(users.map((u) => u.grade).filter(Boolean))].sort()
const classes = [...new Set(users.map((u) => u.class).filter(Boolean))].sort()
allGrades.value = grades
allClasses.value = classes
}
} catch (err) {
console.error('获取所有学生数据失败:', err)
}
}
// 监听显示状态
watch(
() => props.show,
(newVal) => {
if (newVal) {
fetchAllStudents()
// 重置状态
selectedUserIds.value = []
excelPreviewData.value = []
targetStatus.value = ''
statusReason.value = ''
error.value = ''
}
}
)
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #4a5568;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #718096;
}
</style>
+575
View File
@@ -0,0 +1,575 @@
<template>
<div
class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2 animate-in fade-in slide-in-from-bottom-4 duration-700"
>
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
歌曲黑名单管理
</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
</p>
</div>
</div>
<!-- 添加黑名单项表单 -->
<section class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-8 shadow-xl">
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mb-6 px-1">
添加黑名单项
</h3>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
<div class="xl:col-span-2 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁类型</label
>
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg">
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'SONG' ? 'bg-zinc-800 text-blue-400' : 'text-zinc-600'"
@click="newItem.type = 'SONG'"
>
<Music :size="12" /> 具体歌曲
</button>
<button
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
:class="newItem.type === 'KEYWORD' ? 'bg-zinc-800 text-purple-400' : 'text-zinc-600'"
@click="newItem.type = 'KEYWORD'"
>
<Type :size="12" /> 关键词
</button>
</div>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
{{ newItem.type === 'SONG' ? '歌曲名称 - 艺术家' : '关键词内容' }}
</label>
<input
v-model="newItem.value"
type="text"
:placeholder="
newItem.type === 'SONG' ? '例如: 歌曲名 - 艺术家' : '例如: 敏感词、特定风格'
"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
>
</div>
<div class="xl:col-span-4 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁原因 (可选)</label
>
<input
v-model="newItem.reason"
type="text"
placeholder="简述加入黑名单的原因"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
>
</div>
<div class="xl:col-span-2">
<button
:disabled="!newItem.value || loading"
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="addBlacklistItem"
>
<Plus v-if="!loading" :size="16" />
<div
v-else
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
{{ loading ? '处理中...' : '添加到黑名单' }}
</button>
</div>
</div>
</section>
<!-- 筛选和搜索 -->
<div
class="bg-zinc-900/20 border border-zinc-800 rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
>
<div class="flex-1 relative w-full">
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700" :size="18" />
<input
v-model="filters.search"
type="text"
placeholder="在黑名单中搜索..."
class="w-full bg-zinc-950/50 border border-zinc-800 rounded-lg pl-12 pr-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
@input="debounceSearch"
>
</div>
<div class="flex gap-3 w-full md:w-auto">
<CustomSelect
label="类型筛选"
:value="typeFilterLabel"
:options="['全部类型', '具体歌曲', '关键词']"
class="flex-1 md:w-48"
@change="handleTypeFilterChange"
/>
<button
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-600 hover:text-blue-400 transition-all active:scale-95"
@click="loadBlacklist"
>
<Filter :size="18" />
</button>
</div>
</div>
<!-- 黑名单列表 -->
<div class="space-y-4">
<div class="flex items-center justify-between px-2">
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
黑名单列表 ({{ pagination.total }} 项)
</h4>
</div>
<div class="grid grid-cols-1 gap-3">
<TransitionGroup
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in absolute w-full"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-for="item in blacklist"
:key="item.id"
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-zinc-900/30 border rounded-xl transition-all hover:border-zinc-700"
:class="[item.isActive ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60']"
>
<div class="flex-1 flex items-start gap-5">
<div
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
:class="[
item.type === 'SONG'
? 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
: 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
]"
>
<Music v-if="item.type === 'SONG'" :size="22" />
<Type v-else :size="22" />
</div>
<div class="space-y-1.5 min-w-0">
<div class="flex items-center gap-3">
<span
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
:class="[
item.type === 'SONG'
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
: 'bg-purple-600/10 text-purple-400 border-purple-500/20'
]"
>
{{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
</span>
<h5 class="text-base font-black text-zinc-100 truncate tracking-tight">
{{ item.value }}
</h5>
</div>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2 text-zinc-500 font-bold text-[11px]">
<MessageSquare :size="12" class="text-zinc-700" />
原因: {{ item.reason || '未填写原因' }}
</div>
<div
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
>
<Clock :size="11" class="text-zinc-800" />
创建时间: {{ formatDate(item.createdAt) }}
</div>
</div>
</div>
</div>
<div
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-800/50"
>
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
:class="[
item.isActive
? 'bg-zinc-800 text-zinc-500 hover:text-amber-500 hover:bg-amber-500/5 hover:border-amber-500/10 border border-transparent'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="toggleItemStatus(item)"
>
<Power :size="12" />
{{ item.isActive ? '禁用' : '启用' }}
</button>
<button
:disabled="loading"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-700 hover:text-red-500 hover:border-red-500/30 transition-all active:scale-95"
@click="deleteItem(item)"
>
<Trash2 :size="16" />
</button>
</div>
</div>
</TransitionGroup>
<!-- 加载状态 -->
<div
v-if="loading && blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center space-y-4"
>
<div
class="w-12 h-12 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin"
/>
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
</div>
<!-- 空状态 -->
<div
v-else-if="blacklist.length === 0"
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-zinc-900/10 border border-zinc-800/40 border-dashed rounded-xl"
>
<div
class="w-16 h-16 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-800 shadow-xl"
>
<Ban :size="32" />
</div>
<div class="space-y-1 px-4">
<h6 class="text-sm font-bold text-zinc-600">未找到匹配的黑名单项</h6>
<p class="text-[10px] text-zinc-700 font-bold uppercase tracking-widest">
请尝试调整筛选条件或添加新项目
</p>
</div>
</div>
</div>
<!-- 分页控制 -->
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
<button
:disabled="pagination.page <= 1 || loading"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page - 1)"
>
<ChevronLeft :size="20" />
</button>
<div
class="px-6 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-[11px] font-black text-zinc-400 uppercase tracking-widest"
>
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
</div>
<button
:disabled="pagination.page >= pagination.pages || loading"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page + 1)"
>
<ChevronRight :size="20" />
</button>
</div>
</div>
<!-- 删除确认模态框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showDeleteDialog"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
@click.stop
>
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
<div
class="w-16 h-16 rounded-lg bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-2xl shadow-red-900/10"
>
<Trash2 :size="28" />
</div>
<div class="space-y-2">
<h4 class="text-lg font-bold text-zinc-100">确认从黑名单移除</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
确定要移除
<span class="text-zinc-300 font-bold">"{{ deleteTargetItem?.value }}"</span>
吗?移除后,该项目将恢复为正常状态。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
@click="showDeleteDialog = false"
>
取消
</button>
<button
:disabled="loading"
class="flex-1 px-4 py-4 bg-red-600 hover:bg-red-500 text-white text-[10px] font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
@click="confirmDelete"
>
{{ loading ? '删除中...' : '确认移除' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { onMounted, reactive, ref, computed } from 'vue'
import {
ShieldAlert,
Music,
Type,
Plus,
Search,
Filter,
MessageSquare,
Clock,
Power,
Trash2,
Ban,
ChevronLeft,
ChevronRight,
ChevronDown
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const { showToast: showNotification } = useToast()
const blacklist = ref([])
const loading = ref(false)
const error = ref('')
const success = ref('')
// 删除对话框相关
const showDeleteDialog = ref(false)
const deleteTargetItem = ref(null)
const newItem = reactive({
type: 'SONG',
value: '',
reason: ''
})
const filters = reactive({
type: '',
search: ''
})
const pagination = reactive({
page: 1,
limit: 20,
total: 0,
pages: 0
})
const typeFilterLabel = computed(() => {
if (filters.type === 'SONG') return '具体歌曲'
if (filters.type === 'KEYWORD') return '关键词'
return '全部类型'
})
let searchTimeout = null
// 加载黑名单
const loadBlacklist = async () => {
loading.value = true
error.value = ''
try {
const params = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
...(filters.type && { type: filters.type }),
...(filters.search && { search: filters.search })
})
const response = await $fetch(`/api/admin/blacklist?${params}`, {
...useAuth().getAuthConfig()
})
blacklist.value = response.blacklist
Object.assign(pagination, response.pagination)
} catch (err) {
error.value = '获取黑名单失败'
console.error('获取黑名单失败:', err)
showNotification('获取黑名单失败: ' + (err.data?.message || err.message), 'error')
} finally {
loading.value = false
}
}
// 处理类型筛选变更
const handleTypeFilterChange = (label) => {
if (label === '具体歌曲') filters.type = 'SONG'
else if (label === '关键词') filters.type = 'KEYWORD'
else filters.type = ''
pagination.page = 1
loadBlacklist()
}
// 添加黑名单项
const addBlacklistItem = async () => {
if (!newItem.value.trim()) return
loading.value = true
error.value = ''
success.value = ''
try {
await $fetch('/api/admin/blacklist', {
method: 'POST',
body: {
type: newItem.type,
value: newItem.value.trim(),
reason: newItem.reason.trim() || null
},
...useAuth().getAuthConfig()
})
showNotification('黑名单项添加成功', 'success')
// 重置表单
newItem.value = ''
newItem.reason = ''
// 重新加载列表
pagination.page = 1
await loadBlacklist()
} catch (err) {
error.value = err.data?.message || '添加黑名单项失败'
console.error('添加黑名单项失败:', err)
showNotification('添加失败: ' + error.value, 'error')
} finally {
loading.value = false
}
}
// 切换项目状态
const toggleItemStatus = async (item) => {
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${item.id}`, {
method: 'PATCH',
body: {
isActive: !item.isActive
},
...useAuth().getAuthConfig()
})
item.isActive = !item.isActive
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
} catch (err) {
error.value = '更新状态失败'
console.error('更新状态失败:', err)
showNotification('更新状态失败', 'error')
} finally {
loading.value = false
}
}
// 删除项目
const deleteItem = (item) => {
deleteTargetItem.value = item
showDeleteDialog.value = true
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetItem.value) return
loading.value = true
error.value = ''
try {
await $fetch(`/api/admin/blacklist/${deleteTargetItem.value.id}`, {
method: 'DELETE',
...useAuth().getAuthConfig()
})
showNotification('黑名单项删除成功', 'success')
await loadBlacklist()
} catch (err) {
error.value = '删除失败'
console.error('删除失败:', err)
showNotification('删除失败: ' + err.message, 'error')
} finally {
loading.value = false
showDeleteDialog.value = false
deleteTargetItem.value = null
}
}
// 搜索防抖
const debounceSearch = () => {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
pagination.page = 1
loadBlacklist()
}, 500)
}
// 切换页面
const changePage = (page) => {
pagination.page = page
loadBlacklist()
}
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return '-'
return new Date(dateString).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
})
}
onMounted(() => {
loadBlacklist()
})
</script>
<style scoped>
.animate-in {
animation-duration: 0.5s;
animation-fill-mode: both;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
/* 隐藏滚动条但保持功能 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
</style>
+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
+883
View File
@@ -0,0 +1,883 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<div class="space-y-1">
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">数据库操作</h2>
<p class="text-xs text-zinc-500 font-medium">
执行系统底层维护任务,包括备份、恢复及全局数据重置
</p>
</div>
<!-- 操作卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div
v-for="card in cards"
:key="card.id"
:class="[
'group relative bg-zinc-900/40 border border-zinc-800 rounded-2xl p-8 transition-all hover:border-zinc-700 hover:shadow-2xl hover:shadow-black/40',
card.isDanger ? 'hover:border-rose-500/20' : ''
]"
>
<div class="flex flex-col h-full space-y-6">
<div class="flex items-center justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
card.isDanger
? 'text-rose-500 border-rose-500/10'
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
]"
>
<component :is="card.icon" class="w-6 h-6" />
</div>
<span
v-if="card.isDanger"
class="px-2 py-0.5 bg-rose-500/10 text-rose-500 text-[9px] font-black uppercase tracking-widest border border-rose-500/20 rounded"
>高风险操作</span
>
</div>
<div class="flex-1 space-y-2">
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
{{ card.title }}
</h3>
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
{{ card.desc }}
</p>
</div>
<button
:disabled="isLoading(card.id)"
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-zinc-950 border border-zinc-800 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 hover:border-zinc-700"
:class="
card.isDanger
? 'bg-zinc-950 border border-rose-900/30 text-rose-500 hover:bg-rose-600 hover:text-white hover:border-rose-600 shadow-lg shadow-rose-900/5'
: ''
"
@click="openModal(card.id)"
>
<span v-if="isLoading(card.id)">执行中...</span>
<span v-else>{{ card.btnText }}</span>
</button>
</div>
<!-- 背景装饰 -->
<div
:class="[
'absolute -right-4 -bottom-4 w-32 h-32 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity rounded-full pointer-events-none',
card.isDanger ? 'bg-rose-500/5' : `bg-${card.color}-500/5`
]"
/>
</div>
</div>
<!-- 维护建议 -->
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
<AlertCircle class="text-blue-500 shrink-0 mt-0.5 w-[18px] h-[18px]" />
<div class="space-y-1">
<p class="text-[11px] font-bold text-zinc-300">数据库维护建议</p>
<p class="text-[10px] text-zinc-500 leading-relaxed">
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
</p>
</div>
</div>
<!-- 创建备份模态框 -->
<div
v-if="activeModal === 'backup'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">创建数据库备份</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
选择包含的内容
</p>
<div class="space-y-2">
<label
v-for="(item, i) in backupOptions"
:key="i"
class="flex items-start gap-4 p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-700 transition-all group"
>
<div class="shrink-0 mt-0.5">
<input
v-model="createForm[item.key]"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
>
</div>
<div>
<p
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ item.label }}
</p>
<p class="text-[10px] text-zinc-600 font-medium mt-0.5">{{ item.desc }}</p>
</div>
</label>
</div>
</div>
<div class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl">
<p class="text-[10px] text-zinc-500 text-center italic">
备份文件将以 .json 格式生成并自动下载
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="createLoading"
class="px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="createBackup"
>
{{ createLoading ? '正在导出...' : '开始导出' }}
</button>
</div>
</div>
</div>
<!-- 恢复备份模态框 -->
<div
v-if="activeModal === 'restore'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">恢复数据库备份</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div
class="border-2 border-dashed border-zinc-800 rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-emerald-500/50 hover:bg-emerald-500/5 transition-all cursor-pointer"
@click="$refs.fileInput.click()"
@dragover.prevent
@drop.prevent="handleFileDrop"
>
<Upload
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
/>
<h5 class="text-sm font-bold text-zinc-300">
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
</h5>
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
仅支持 VoiceHub 导出的 .json 格式
</p>
<input
ref="fileInput"
accept=".json"
class="hidden"
type="file"
@change="handleFileSelect"
>
</div>
<div class="space-y-3">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>恢复模式</label
>
<div class="grid grid-cols-2 gap-3">
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'merge'
? 'bg-zinc-950 border-emerald-500/30'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="restoreForm.mode = 'merge'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
增量模式
</h6>
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">仅导入不重复的新记录</p>
</button>
<button
:class="[
'p-4 border rounded-xl text-left transition-all',
restoreForm.mode === 'replace'
? 'bg-zinc-950 border-emerald-500/30'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="restoreForm.mode = 'replace'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
]"
>
覆盖模式
</h6>
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">清空现有表后完整恢复</p>
</button>
</div>
</div>
<div
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
>
<label class="flex items-start gap-3 cursor-pointer">
<input
v-model="restoreForm.overwriteSuperAdmin"
type="checkbox"
class="mt-0.5 accent-emerald-500"
>
<div>
<p class="text-xs font-bold text-zinc-200">覆盖备份中的超级管理员账号数据</p>
<p class="text-[10px] text-zinc-500 mt-1">
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
</p>
</div>
</label>
</div>
<div
class="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-amber-500 shrink-0 mt-0.5 w-4 h-4" />
<p class="text-[10px] text-zinc-500 leading-normal font-medium">
注意:覆盖模式将永久清空当前数据库中对应的表内容。此操作将导致现有会话中断。
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="uploadLoading || !selectedFile"
class="px-8 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="restoreBackup"
>
{{ uploadLoading ? restoreProgress || '正在恢复...' : '确认并开始恢复' }}
</button>
</div>
</div>
</div>
<!-- 重置序列模态框 -->
<div
v-if="activeModal === 'reset-seq'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置数据表序列</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择目标表</label
>
<CustomSelect
v-model="sequenceForm.table"
:options="tableOptions"
class="w-full"
/>
</div>
<div class="p-6 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-4">
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
>
<AlertCircle class="w-4 h-4" />
</div>
<h6 class="text-xs font-bold text-zinc-300 uppercase tracking-widest">
什么是重置序列?
</h6>
</div>
<p class="text-[11px] text-zinc-500 leading-relaxed font-medium">
如果您的数据表 ID
出现了断档或在手动操作数据库后无法自增,重置序列可以将数据库底层的计数器更新为当前 ID
最大值 +1,从而解决 ID 冲突导致的写入失败问题。此操作不会修改任何现有数据。
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="sequenceLoading || !sequenceForm.table"
class="px-8 py-2 bg-amber-600 hover:bg-amber-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetSequence"
>
{{ sequenceLoading ? '正在重置...' : '执行重置' }}
</button>
</div>
</div>
</div>
<!-- 重置数据库模态框 -->
<div
v-if="activeModal === 'reset-db'"
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
<div
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
>
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-xl font-black text-rose-500 tracking-tight">危险操作:重置数据库</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
</button>
</div>
<div class="p-8 space-y-6">
<div
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
>
<Trash2 class="text-rose-500 mb-4 w-12 h-12" />
<h4 class="text-lg font-black text-rose-500 tracking-tight">
您正在执行极其危险的操作!
</h4>
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
重置操作将永久删除系统中的所有
<span class="text-zinc-300 font-bold"
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
>。
</p>
</div>
<div class="space-y-3">
<label
class="text-[11px] font-black text-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
>
请输入以下代码以确认操作
</label>
<div
class="bg-zinc-950 border border-rose-900/30 rounded-xl px-4 py-3 font-mono text-[10px] text-rose-400 text-center select-all"
>
{{ CONFIRM_CODE }}
</div>
<input
v-model="resetConfirmText"
type="text"
placeholder="在此输入上述代码..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-rose-500/40 text-center font-mono placeholder:text-zinc-700"
>
</div>
<div class="grid grid-cols-2 gap-3 pt-2">
<button
class="py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
</button>
<button
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-zinc-800 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-700"
:class="
resetConfirmText === CONFIRM_CODE
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
: ''
"
@click="resetDatabase"
>
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { useToast } from '~/composables/useToast'
import { useAuth } from '~/composables/useAuth'
const { showToast: showNotification } = useToast()
const auth = useAuth()
// 状态
const activeModal = ref('none')
const createLoading = ref(false)
const uploadLoading = ref(false)
const sequenceLoading = ref(false)
const resetLoading = ref(false)
const selectedFile = ref(null)
const resetConfirmText = ref('')
const restoreProgress = ref('')
const hasSuperAdminInBackup = ref(false)
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
// 卡片配置
const cards = [
{
id: 'backup',
title: '创建备份',
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
icon: Download,
color: 'blue',
btnText: '创建备份文件'
},
{
id: 'restore',
title: '恢复备份',
desc: '从之前导出的备份文件中恢复系统数据。',
icon: Upload,
color: 'emerald',
btnText: '选择备份文件'
},
{
id: 'reset-seq',
title: '重置序列',
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
icon: RotateCw,
color: 'amber',
btnText: '开始重置序列'
},
{
id: 'reset-db',
title: '重置数据库',
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
icon: Trash2,
color: 'rose',
btnText: '立即重置数据库',
isDanger: true
}
]
// 备份选项
const backupOptions = [
{
key: 'includeSongs',
label: '歌曲与排期数据',
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
},
{
key: 'includeSystemData',
label: '系统配置信息',
desc: '包含站点设置、黑名单、播出时段等全局参数'
},
{
key: 'includeUsers',
label: '用户账户数据',
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
}
]
// 表单数据
const createForm = ref({
includeSongs: true,
includeUsers: true,
includeSystemData: true
})
const restoreForm = ref({
mode: 'merge',
overwriteSuperAdmin: false
})
const sequenceForm = ref({
table: '重置所有表 (All)'
})
const tableOptions = [
'重置所有表 (All)',
'歌曲表 (Song)',
'用户表 (User)',
'投票表 (Vote)',
'排期表 (Schedule)',
'通知表 (Notification)',
'通知设置表 (NotificationSettings)',
'播放时段表 (PlayTime)',
'学期表 (Semester)',
'系统设置表 (SystemSettings)',
'歌曲黑名单表 (SongBlacklist)'
]
const labelToValueMap = {
'重置所有表 (All)': 'all',
'歌曲表 (Song)': 'Song',
'用户表 (User)': 'User',
'投票表 (Vote)': 'Vote',
'排期表 (Schedule)': 'Schedule',
'通知表 (Notification)': 'Notification',
'通知设置表 (NotificationSettings)': 'NotificationSettings',
'播放时段表 (PlayTime)': 'PlayTime',
'学期表 (Semester)': 'Semester',
'系统设置表 (SystemSettings)': 'SystemSettings',
'歌曲黑名单表 (SongBlacklist)': 'SongBlacklist'
}
// 辅助函数
const isLoading = (id) => {
if (id === 'backup') return createLoading.value
if (id === 'restore') return uploadLoading.value
if (id === 'reset-seq') return sequenceLoading.value
if (id === 'reset-db') return resetLoading.value
return false
}
const openModal = (id) => {
activeModal.value = id
if (id === 'reset-db') {
resetConfirmText.value = ''
}
}
// 文件处理
const parseBackupSuperAdmin = async (file) => {
try {
const fileContent = await file.text()
const backupData = JSON.parse(fileContent)
const users = Array.isArray(backupData?.data?.users) ? backupData.data.users : []
hasSuperAdminInBackup.value = users.some((item) => item?.role === 'SUPER_ADMIN')
if (!hasSuperAdminInBackup.value) {
restoreForm.value.overwriteSuperAdmin = false
}
} catch (error) {
hasSuperAdminInBackup.value = false
restoreForm.value.overwriteSuperAdmin = false
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
console.error('解析备份文件失败:', error)
}
}
const handleFileSelect = async (event) => {
const file = event.target.files[0]
if (file && file.type === 'application/json') {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification('请选择有效的JSON备份文件', 'error')
}
}
const handleFileDrop = async (event) => {
const file = event.dataTransfer.files[0]
if (file && file.type === 'application/json') {
selectedFile.value = file
await parseBackupSuperAdmin(file)
} else {
showNotification('请选择有效的JSON备份文件', 'error')
}
}
// API 操作
const createBackup = async () => {
createLoading.value = true
try {
let tables = 'all'
if (createForm.value.includeUsers && createForm.value.includeSongs) {
tables = 'all'
} else if (createForm.value.includeUsers) {
tables = 'users'
} else if (createForm.value.includeSongs) {
tables = 'songs'
} else if (createForm.value.includeSystemData) {
tables = ['systemSettings']
} else {
throw new Error('请至少选择一项备份内容')
}
const response = await $fetch('/api/admin/backup/export', {
method: 'POST',
body: {
tables,
includeSystemData: createForm.value.includeSystemData
}
})
if (response.success && response.backup) {
if (response.backup.downloadMode === 'direct' && response.backup.data) {
const blob = new Blob([JSON.stringify(response.backup.data, null, 2)], {
type: 'application/json'
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = response.backup.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
const downloadResponse = await fetch(downloadUrl, {
method: 'GET',
credentials: 'include'
})
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
const blob = await downloadResponse.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = response.backup.filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
showNotification('备份文件已下载', 'success')
activeModal.value = 'none'
}
} else {
throw new Error(response.message || '备份创建失败')
}
} catch (error) {
console.error('创建备份失败:', error)
showNotification('创建备份失败: ' + error.message, 'error')
} finally {
createLoading.value = false
}
}
const restoreBackup = async () => {
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
uploadLoading.value = true
restoreProgress.value = '正在读取文件...'
try {
const fileContent = await selectedFile.value.text()
let backupData
try {
backupData = JSON.parse(fileContent)
} catch {
throw new Error('无法解析备份文件,文件格式不正确')
}
if (!backupData.data) throw new Error('备份文件缺少数据部分')
const fileHasSuperAdmin = hasSuperAdminInBackup.value
if (!fileHasSuperAdmin) {
restoreForm.value.overwriteSuperAdmin = false
}
let preservedSuperAdminIds = []
let temporaryPreservedUserId = null
if (restoreForm.value.mode === 'replace') {
restoreProgress.value = '正在清空现有数据...'
const clearResult = await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
}
const tableOrder = [
'users',
'userIdentities',
'systemSettings',
'semesters',
'playTimes',
'songs',
'votes',
'schedules',
'notificationSettings',
'notifications',
'songBlacklist',
'userStatusLogs'
]
const mappings = {
users: {},
songs: {},
meta: {
preservedSuperAdminIds,
temporaryPreservedUserId
}
}
const CHUNK_SIZE = 50
let totalProcessed = 0
let totalRecords = 0
for (const tableName of tableOrder) {
if (backupData.data[tableName] && Array.isArray(backupData.data[tableName])) {
totalRecords += backupData.data[tableName].length
}
}
for (const tableName of tableOrder) {
const records = backupData.data[tableName]
if (!records || !Array.isArray(records) || records.length === 0) continue
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
const chunk = records.slice(i, i + CHUNK_SIZE)
const progressPercent = Math.round((totalProcessed / totalRecords) * 100)
restoreProgress.value = `正在恢复 ${tableName} (${i + 1}-${Math.min(i + CHUNK_SIZE, records.length)}/${records.length}) ${progressPercent}%`
const response = await $fetch('/api/admin/backup/restore-chunk', {
method: 'POST',
body: {
tableName,
records: chunk,
mappings,
mode: restoreForm.value.mode,
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
hasSuperAdminInBackup: fileHasSuperAdmin
}
})
if (!response.success) throw new Error(response.message || `恢复表 ${tableName} 失败`)
if (response.newMappings) {
if (response.newMappings.users) Object.assign(mappings.users, response.newMappings.users)
if (response.newMappings.songs) Object.assign(mappings.songs, response.newMappings.songs)
}
totalProcessed += chunk.length
}
}
restoreProgress.value = '正在修复数据表序列...'
const sequenceResult = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: 'all' }
})
if (!sequenceResult.success) {
throw new Error(sequenceResult.message || sequenceResult.error || '序列修复失败')
}
restoreProgress.value = '正在重载SMTP配置...'
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!smtpReloadResult.success) {
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
}
const shouldFinalizeTempUser =
restoreForm.value.mode === 'replace' &&
restoreForm.value.overwriteSuperAdmin &&
fileHasSuperAdmin &&
temporaryPreservedUserId
if (shouldFinalizeTempUser) {
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
restoreProgress.value = '正在完成管理员替换...'
await $fetch('/api/admin/backup/clear', {
method: 'POST',
body: {
finalizeTempUser: true
}
})
}
showNotification('数据库恢复成功,正在重新登录', 'success')
activeModal.value = 'none'
setTimeout(() => {
if (auth.logout) {
auth.logout()
}
localStorage.removeItem('auth-token')
localStorage.removeItem('user-info')
window.location.href = '/'
}, 1200)
return
}
showNotification('数据库恢复成功', 'success')
activeModal.value = 'none'
} catch (error) {
console.error('恢复备份失败:', error)
showNotification('恢复备份失败: ' + error.message, 'error')
} finally {
uploadLoading.value = false
restoreProgress.value = ''
}
}
const resetSequence = async () => {
if (!sequenceForm.value.table) return
sequenceLoading.value = true
try {
const tableValue = labelToValueMap[sequenceForm.value.table] || sequenceForm.value.table
const response = await $fetch('/api/admin/fix-sequence', {
method: 'POST',
body: { table: tableValue }
})
if (response.success) {
showNotification(response.message || '序列重置成功', 'success')
activeModal.value = 'none'
} else {
throw new Error(response.error || response.message || '重置失败')
}
} catch (error) {
console.error('重置序列失败:', error)
showNotification('重置序列失败: ' + error.message, 'error')
} finally {
sequenceLoading.value = false
}
}
const resetDatabase = async () => {
if (resetConfirmText.value !== CONFIRM_CODE) return
resetLoading.value = true
try {
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
if (response.success) {
showNotification('数据库已成功重置', 'success')
activeModal.value = 'none'
setTimeout(() => window.location.reload(), 1500)
} else {
throw new Error(response.message || '重置失败')
}
} catch (error) {
console.error('重置数据库失败:', error)
showNotification('重置数据库失败: ' + error.message, 'error')
} finally {
resetLoading.value = false
}
}
</script>
@@ -0,0 +1,345 @@
<template>
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] overflow-hidden">
<!-- 头部 -->
<div class="flex items-center justify-between p-6 border-b border-zinc-800 bg-zinc-900/20">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center border border-blue-500/20"
>
<Mail :size="20" class="text-blue-500" />
</div>
<div>
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">邮件模板管理</h3>
<p class="text-[10px] text-zinc-500 mt-0.5">自定义系统邮件的主题与内容,支持变量替换</p>
</div>
</div>
<div class="flex items-center gap-3">
<button
v-if="selected"
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-[11px] font-bold rounded-xl transition-all"
:disabled="saving"
@click="doPreview"
>
<Eye :size="14" /> {{ previewHtml ? '刷新预览' : '实时预览' }}
</button>
<button
v-if="selected"
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
:disabled="saving"
@click="save"
>
<Save :size="14" /> {{ saving ? '保存中...' : '更新模板' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 min-h-[650px]">
<!-- 左侧:模板列表 -->
<div class="lg:col-span-3 border-r border-zinc-800 bg-zinc-900/10 flex flex-col">
<div class="p-4 border-b border-zinc-800/50">
<div class="relative">
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
<input
type="text"
placeholder="搜索模板..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-[10px] text-zinc-400 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
<button
v-for="t in templates"
:key="t.key"
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
:class="[
selectedKey === t.key
? 'bg-blue-600/10 border-blue-500/20'
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
]"
@click="select(t)"
>
<div class="flex items-center justify-between">
<span
class="text-[11px] font-black tracking-tight transition-colors"
:class="
selectedKey === t.key
? 'text-blue-400'
: 'text-zinc-300 group-hover:text-zinc-100'
"
>
{{ t.name }}
</span>
<div class="flex gap-1">
<span
v-if="t.isBuiltin && !t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-zinc-800 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
>
内置
</span>
<span
v-if="t.isOverridden"
class="px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-[8px] font-black text-emerald-500 uppercase tracking-tighter"
>
自定义
</span>
</div>
</div>
<span class="text-[9px] font-bold text-zinc-600 font-mono">{{ t.key }}</span>
</button>
</div>
</div>
<!-- 右侧:编辑器区域 -->
<div class="lg:col-span-9 flex flex-col bg-zinc-950/20 overflow-hidden">
<div v-if="selected" class="flex-1 overflow-y-auto custom-scrollbar p-8">
<div class="max-w-4xl mx-auto space-y-8">
<!-- 基础信息与主题 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>模板名称</label
>
<input
v-model="form.name"
type="text"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between px-1">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>邮件主题</label
>
<span class="text-[9px] text-zinc-500 font-bold uppercase">支持变量</span>
</div>
<input
v-model="form.subject"
type="text"
placeholder="请输入邮件主题..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
<!-- 变量提示 -->
<div class="bg-blue-600/5 border border-blue-500/10 rounded-2xl p-4 flex gap-4">
<div
class="w-8 h-8 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0"
>
<Info :size="16" class="text-blue-500" />
</div>
<div class="space-y-1">
<h4 class="text-[10px] font-black text-blue-400 uppercase tracking-widest">
可用变量
</h4>
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
<template v-if="selected.key === 'verification.code'">
<span v-pre>{{ name }}, {{ email }}, {{ code }}, {{ expiresInMinutes }}</span>
</template>
<template v-else>
<span v-pre>{{ title }}, {{ message }}, {{ actionUrl }}, {{ fromName }}</span>
</template>
</p>
</div>
</div>
<!-- 内容编辑器 -->
<div class="space-y-3">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>HTML 内容</label
>
<span
class="px-2 py-0.5 rounded-md bg-zinc-900 text-[8px] font-black text-zinc-500 uppercase"
>Handlebars</span
>
</div>
<button
v-if="selected.isOverridden"
class="flex items-center gap-1.5 text-[10px] font-bold text-rose-500 hover:text-rose-400 transition-colors"
@click="restore"
>
<RotateCcw :size="12" /> 恢复默认配置
</button>
</div>
<div class="relative group">
<div
class="absolute right-4 top-4 p-2 rounded-lg bg-zinc-900/50 border border-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Code :size="14" class="text-zinc-500" />
</div>
<textarea
v-model="form.html"
rows="16"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl p-6 text-[11px] text-zinc-300 font-mono leading-relaxed focus:outline-none focus:border-blue-500/30 custom-scrollbar"
placeholder="<html>..."
/>
</div>
</div>
<!-- 预览框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform translate-y-4 opacity-0"
enter-to-class="transform translate-y-0 opacity-100"
>
<div v-if="previewHtml" class="space-y-4 pt-4 border-t border-zinc-800">
<div class="flex items-center justify-between px-1">
<label
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
>
<Eye :size="14" /> 实时预览:
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
</label>
<button
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300"
@click="previewHtml = ''"
>
隐藏预览
</button>
</div>
<div class="rounded-2xl overflow-hidden border border-zinc-800 bg-white shadow-2xl">
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
</div>
</div>
</Transition>
</div>
</div>
<!-- 空状态 -->
<div
v-else
class="flex-1 flex flex-col items-center justify-center text-zinc-700 space-y-4"
>
<div
class="w-16 h-16 rounded-3xl bg-zinc-900/50 flex items-center justify-center border border-zinc-800/50"
>
<Mail :size="32" class="text-zinc-800" />
</div>
<p class="text-xs font-bold tracking-widest uppercase">请在左侧选择一个邮件模板</p>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from '@lucide/vue'
type TemplateItem = {
key: string
name: string
subject: string
html: string
isBuiltin: boolean
isOverridden?: boolean
}
const { success, error } = useToast()
const templates = ref<TemplateItem[]>([])
const selectedKey = ref<string>('')
const form = ref<{ key: string; name: string; subject: string; html: string }>({
key: '',
name: '',
subject: '',
html: ''
})
const saving = ref(false)
const previewHtml = ref<string>('')
const previewSubject = ref<string>('')
const selected = computed(() => templates.value.find((t) => t.key === selectedKey.value))
async function loadList() {
const res: any = await $fetch('/api/admin/email-templates')
const list = Array.isArray(res?.templates) ? res.templates : []
templates.value = list.map((t: any) => ({
key: String(t.key),
name: String(t.name),
subject: String(t.subject),
html: String(t.html),
isBuiltin: !!t.isBuiltin,
isOverridden: !!t.isOverridden
}))
if (!selectedKey.value && templates.value.length) select(templates.value[0])
}
function select(t: TemplateItem) {
selectedKey.value = t.key
form.value = { key: t.key, name: t.name, subject: t.subject, html: t.html }
previewHtml.value = ''
previewSubject.value = ''
}
async function save() {
try {
saving.value = true
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
success('模板已保存')
await loadList()
} catch (e: any) {
error(e?.data?.message || '保存失败')
} finally {
saving.value = false
}
}
async function restore() {
try {
saving.value = true
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
method: 'DELETE'
})
success('已恢复默认模板')
await loadList()
} catch (e: any) {
error(e?.data?.message || '恢复失败')
} finally {
saving.value = false
}
}
async function doPreview() {
try {
const defaultData =
form.value.key === 'verification.code'
? { name: '张三', email: 'example@school.edu', code: '123456', expiresInMinutes: 5 }
: {
title: '系统通知',
message: '这是一条预览内容\n支持换行与链接',
actionUrl: 'https://example.com'
}
const res = await $fetch('/api/admin/email-templates/preview', {
method: 'POST',
body: { key: form.value.key, data: defaultData }
})
previewHtml.value = res.html
previewSubject.value = res.subject
} catch (e: any) {
error(e?.data?.message || '预览失败')
}
}
onMounted(() => {
loadList()
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
+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>
+768
View File
@@ -0,0 +1,768 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">向用户发送通知</h2>
<p class="text-xs text-zinc-500 mt-1">即时推送系统公告、活动信息或重要提醒至指定群体</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
<!-- 左侧:编辑区 -->
<div class="lg:col-span-7 space-y-6">
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl">
<div class="space-y-6">
<!-- 标题 -->
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知标题</label
>
<input
v-model="form.title"
type="text"
placeholder="请输入通知标题"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800"
>
</div>
<!-- 内容 -->
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知内容</label
>
<textarea
v-model="form.content"
placeholder="请输入通知详情内容..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800 min-h-[160px] resize-none"
/>
</div>
<!-- 范围选择 -->
<div class="space-y-3 pt-4 border-t border-zinc-800/50">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知范围</label
>
<div class="flex flex-wrap gap-2">
<button
v-for="opt in targetOptions"
:key="opt.id"
:class="[
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
form.scope === opt.id
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-950 border-zinc-800 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300'
]"
@click="form.scope = opt.id"
>
<component :is="opt.icon" :size="14" />
{{ opt.label }}
</button>
</div>
</div>
<!-- 动态配置区 -->
<div class="pt-2">
<Transition name="fade" mode="out-in">
<div
v-if="form.scope === 'ALL'"
key="all"
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-2xl flex items-center gap-4"
>
<Info class="text-blue-500 shrink-0" :size="18" />
<p class="text-[11px] font-bold text-zinc-400">
将向系统中所有已注册的活跃用户发送此通知。
</p>
</div>
<div v-else-if="form.scope === 'GRADE'" key="grade" class="space-y-4">
<div class="space-y-1.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>选择年级</span
>
<CustomSelect
v-model="form.grade"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full md:w-64"
/>
</div>
</div>
<div
v-else-if="form.scope === 'CLASS'"
key="class"
class="grid grid-cols-1 md:grid-cols-2 gap-4"
>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>年级</span
>
<CustomSelect
v-model="form.classGrade"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full"
/>
</div>
<div class="space-y-1.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
>班级</span
>
<CustomSelect
v-model="form.className"
:options="classNumberOptions"
label-key="label"
value-key="value"
placeholder="请选择班级"
class-name="w-full"
/>
</div>
</div>
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
<div class="p-6 bg-zinc-950/50 border border-zinc-800 border-dashed rounded-2xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<CustomSelect
v-model="multiClassForm.grade"
:options="['高一', '高二', '高三', '教师']"
placeholder="请选择年级"
class="w-full"
/>
<div class="flex gap-2">
<CustomSelect
v-model="multiClassForm.class"
:options="classNumberOptions"
label-key="label"
value-key="value"
placeholder="请选择班级"
class-name="flex-1"
/>
<button
:disabled="!canAddClass"
class="px-4 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 font-bold rounded-xl text-xs transition-all"
@click="addClassToSelection"
>
添加
</button>
</div>
</div>
<div v-if="form.selectedClasses.length > 0" class="flex flex-wrap gap-2">
<div
v-for="(cls, index) in form.selectedClasses"
:key="index"
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
>
<span class="text-[10px] font-bold text-zinc-400"
>{{ cls.grade }} {{ cls.class }}</span
>
<button
class="text-zinc-600 hover:text-red-400 transition-colors"
@click="removeClassFromSelection(index)"
>
<X :size="12" />
</button>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
未选择任何班级
</p>
</div>
</div>
</div>
<div v-else-if="form.scope === 'SPECIFIC_USERS'" key="specific" class="space-y-4">
<div class="relative flex items-center">
<Search
v-if="!userSearchLoading"
class="absolute left-4 text-zinc-700 pointer-events-none"
:size="16"
/>
<Loader2
v-else
class="absolute left-4 text-blue-500 animate-spin pointer-events-none"
:size="16"
/>
<input
v-model="userSearchQuery"
type="text"
placeholder="搜索并选择用户 (姓名、用户名或ID)..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
@input="onUserSearchInput"
>
</div>
<!-- 搜索结果 -->
<div
v-if="showUserSearchResults && userSearchResults.length > 0"
class="bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
>
<div class="px-4 py-2 border-b border-zinc-800/60 bg-zinc-900/40">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>搜索结果 ({{ userSearchResults.length }})</span
>
</div>
<div
v-for="user in userSearchResults"
:key="user.id"
class="flex items-center justify-between p-4 border-b border-zinc-800/30 last:border-0 hover:bg-zinc-800/30 transition-all"
>
<div>
<h5 class="text-sm font-bold text-zinc-200">
{{ user.name || user.username }}
</h5>
<div class="flex items-center gap-2 mt-0.5">
<span class="text-[10px] text-blue-500 font-black"
>@{{ user.username }}</span
>
<span
v-if="user.grade && user.class"
class="text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
>{{ user.grade }} {{ user.class }}</span
>
<span class="text-[10px] text-zinc-700 font-black">{{
getRoleText(user.role)
}}</span>
</div>
</div>
<button
:disabled="isUserSelected(user.id)"
class="px-3 py-1.5 bg-zinc-800 hover:bg-blue-600 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase"
@click="addUserToSelection(user)"
>
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
</button>
</div>
</div>
<!-- 已选择的用户 -->
<div v-if="form.selectedUsers.length > 0" class="space-y-3">
<div class="flex items-center justify-between px-1">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>已选择用户 ({{ form.selectedUsers.length }})</span
>
<button
class="text-[9px] font-black text-red-500/70 hover:text-red-500 uppercase tracking-widest transition-colors"
@click="clearAllSelectedUsers"
>
清空全部
</button>
</div>
<div class="flex flex-wrap gap-2">
<div
v-for="(user, index) in form.selectedUsers"
:key="user.id"
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
>
<div class="flex flex-col">
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
user.name || user.username
}}</span>
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
</div>
<button
class="text-zinc-600 hover:text-red-400 transition-colors"
@click="removeUserFromSelection(index)"
>
<X :size="12" />
</button>
</div>
</div>
</div>
<div v-else class="text-center py-4">
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
未选择任何用户
</p>
</div>
</div>
</Transition>
</div>
</div>
<!-- 错误和成功提示 -->
<div v-if="error || success" class="mt-6">
<div
v-if="error"
class="p-4 bg-red-500/5 border border-red-500/10 rounded-2xl flex items-center gap-3"
>
<AlertCircle class="text-red-500 shrink-0" :size="18" />
<p class="text-xs font-bold text-red-400">{{ error }}</p>
</div>
<div
v-if="success"
class="p-4 bg-green-500/5 border border-green-500/10 rounded-2xl flex items-center gap-3"
>
<Check class="text-green-500 shrink-0" :size="18" />
<p class="text-xs font-bold text-green-400">{{ success }}</p>
</div>
</div>
<div class="mt-10 flex justify-end">
<button
:disabled="loading || !isFormValid"
:class="[
'flex items-center gap-2 px-10 py-4 bg-zinc-950 border border-zinc-800 text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
loading || !isFormValid
? 'text-zinc-800 cursor-not-allowed'
: 'text-zinc-400 hover:text-zinc-100 hover:border-zinc-600 hover:bg-zinc-900'
]"
@click="sendNotification"
>
<Loader2 v-if="loading" class="animate-spin" :size="16" />
<Send v-else :size="16" />
{{ loading ? '发送中...' : '发送通知' }}
</button>
</div>
</div>
</div>
<!-- 右侧:预览区 -->
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
<div
class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
>
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest mb-6 flex items-center gap-2"
>
<Eye :size="16" class="text-blue-500" /> 通知预览
</h3>
<div class="flex-1 flex flex-col items-center justify-center p-4">
<div
class="w-full max-w-[320px] bg-[#0c0c0e] border border-zinc-800 rounded-3xl overflow-hidden shadow-2xl relative"
>
<!-- 顶部装饰 -->
<div class="h-1.5 bg-blue-600 w-full" />
<div class="p-6 space-y-4">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<div
class="w-8 h-8 rounded-xl bg-blue-600/10 text-blue-500 flex items-center justify-center border border-blue-500/20"
>
<Bell :size="14" />
</div>
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>VoiceHub 系统</span
>
</div>
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
>刚刚</span
>
</div>
<div class="space-y-2">
<h4
:class="[
'text-sm font-black transition-colors',
form.title ? 'text-zinc-100' : 'text-zinc-800 italic'
]"
>
{{ form.title || '通知标题' }}
</h4>
<p
:class="[
'text-[11px] leading-relaxed transition-colors',
form.content ? 'text-zinc-400' : 'text-zinc-800 italic line-clamp-3'
]"
>
{{ form.content || '这里将显示通知的详细内容预览...' }}
</p>
</div>
<div class="pt-4 border-t border-zinc-800/50 flex items-center justify-between">
<div class="flex items-center gap-1.5">
<Users :size="12" class="text-zinc-700" />
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-wider">
发送范围: {{ scopeDescription }}
</span>
</div>
<button
class="p-1.5 text-blue-500 hover:bg-blue-600/10 rounded-lg transition-all"
>
<MessageSquare :size="14" />
</button>
</div>
</div>
<!-- 背景光晕 -->
<div
class="absolute top-0 right-0 w-32 h-32 bg-blue-600/5 blur-[50px] -z-10 rounded-full"
/>
</div>
<div class="mt-8 space-y-3 w-full max-w-[320px]">
<div
class="flex items-start gap-3 p-3 bg-amber-500/5 border border-amber-500/10 rounded-2xl"
>
<AlertCircle class="text-amber-500 shrink-0 mt-0.5" :size="14" />
<p class="text-[10px] font-bold text-zinc-500 leading-normal">
预览图仅供参考布局,实际发送效果可能会根据用户设备显示语言和屏幕尺寸有所不同。
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, onUnmounted } from 'vue'
import {
Send,
Users,
GraduationCap,
LayoutGrid,
User,
Search,
Bell,
Info,
X,
Check,
Plus,
AlertCircle,
Eye,
MessageSquare,
Loader2
} from '@lucide/vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { useAuth } from '~/composables/useAuth'
import { useAdmin } from '~/composables/useAdmin'
const { isAdmin } = useAuth()
const { sendAdminNotification } = useAdmin()
// 表单数据
const form = ref({
title: '',
content: '',
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
grade: '',
classGrade: '',
className: '',
selectedClasses: [], // 用于多班级选择
selectedUsers: [] // 用于指定用户选择
})
// 多班级选择表单
const multiClassForm = ref({
grade: '',
class: ''
})
const loading = ref(false)
const error = ref('')
const success = ref('')
// 用户搜索相关
const userSearchQuery = ref('')
const userSearchResults = ref([])
const showUserSearchResults = ref(false)
const userSearchLoading = ref(false)
let userSearchTimeout = null
const targetOptions = [
{ id: 'ALL', label: '全体用户', icon: Users },
{ id: 'GRADE', label: '按年级选择', icon: GraduationCap },
{ id: 'CLASS', label: '按班级选择', icon: LayoutGrid },
{ id: 'MULTI_CLASS', label: '多班级选择', icon: Plus },
{ id: 'SPECIFIC_USERS', label: '指定用户', icon: User }
]
const classNumberOptions = Array.from({ length: 16 }, (_, i) => ({
label: `${i + 1}班`,
value: (i + 1).toString()
}))
// 判断是否可以添加班级
const canAddClass = computed(() => {
return multiClassForm.value.grade && multiClassForm.value.class
})
// 添加班级到选择列表
const addClassToSelection = () => {
if (!canAddClass.value) return
// 检查是否已经选择了这个班级
const isDuplicate = form.value.selectedClasses.some(
(cls) => cls.grade === multiClassForm.value.grade && cls.class === multiClassForm.value.class
)
if (!isDuplicate) {
form.value.selectedClasses.push({
grade: multiClassForm.value.grade,
class: multiClassForm.value.class
})
// 清空输入
multiClassForm.value.class = ''
}
}
// 从选择列表中移除班级
const removeClassFromSelection = (index) => {
form.value.selectedClasses.splice(index, 1)
}
// 用户搜索输入处理(防抖)
const onUserSearchInput = () => {
clearTimeout(userSearchTimeout)
if (!userSearchQuery.value.trim()) {
userSearchResults.value = []
showUserSearchResults.value = false
return
}
userSearchTimeout = setTimeout(async () => {
await searchUsers(userSearchQuery.value.trim())
}, 300)
}
// 搜索用户API调用
const searchUsers = async (query) => {
if (!query) return
try {
userSearchLoading.value = true
const response = await $fetch('/api/admin/users', {
method: 'GET',
query: {
search: query,
limit: 20
}
})
if (response.success) {
userSearchResults.value = response.users || []
showUserSearchResults.value = true
}
} catch (err) {
console.error('搜索用户失败:', err)
userSearchResults.value = []
showUserSearchResults.value = false
} finally {
userSearchLoading.value = false
}
}
// 检查用户是否已被选择
const isUserSelected = (userId) => {
return form.value.selectedUsers.some((user) => user.id === userId)
}
// 添加用户到选择列表
const addUserToSelection = (user) => {
if (isUserSelected(user.id)) return
form.value.selectedUsers.push({
id: user.id,
name: user.name,
username: user.username,
grade: user.grade,
class: user.class,
role: user.role
})
// 清空搜索
userSearchQuery.value = ''
userSearchResults.value = []
showUserSearchResults.value = false
}
// 从选择列表中移除用户
const removeUserFromSelection = (index) => {
form.value.selectedUsers.splice(index, 1)
}
// 清空所有已选用户
const clearAllSelectedUsers = () => {
form.value.selectedUsers = []
}
// 获取角色文本
const getRoleText = (role) => {
const roleMap = {
admin: '管理员',
teacher: '教师',
student: '学生'
}
return roleMap[role] || role
}
// 表单验证
const isFormValid = computed(() => {
if (!form.value.title || !form.value.content) {
return false
}
if (form.value.scope === 'GRADE' && !form.value.grade) {
return false
}
if (form.value.scope === 'CLASS' && (!form.value.classGrade || !form.value.className)) {
return false
}
if (form.value.scope === 'MULTI_CLASS' && form.value.selectedClasses.length === 0) {
return false
}
if (form.value.scope === 'SPECIFIC_USERS' && form.value.selectedUsers.length === 0) {
return false
}
return true
})
// 范围描述
const scopeDescription = computed(() => {
switch (form.value.scope) {
case 'ALL':
return '全体用户'
case 'GRADE':
return form.value.grade ? `${form.value.grade}年级` : '请选择年级'
case 'CLASS':
return form.value.classGrade && form.value.className
? `${form.value.classGrade}年级${form.value.className}班`
: '请选择班级'
case 'MULTI_CLASS':
return form.value.selectedClasses.length > 0
? `${form.value.selectedClasses.length}个班级`
: '请选择班级'
case 'SPECIFIC_USERS':
return form.value.selectedUsers.length > 0
? `已选择${form.value.selectedUsers.length}个用户`
: '请选择用户'
default:
return ''
}
})
// 发送通知
const sendNotification = async () => {
if (!isAdmin.value) {
error.value = '只有管理员可以发送系统通知'
return
}
if (!isFormValid.value) {
error.value = '请填写完整信息'
return
}
loading.value = true
error.value = ''
success.value = ''
try {
// 构建请求数据
const notificationData = {
title: form.value.title,
content: form.value.content,
scope: form.value.scope,
filter: {}
}
// 添加过滤条件
if (form.value.scope === 'GRADE') {
notificationData.filter.grade = form.value.grade
} else if (form.value.scope === 'CLASS') {
notificationData.filter.grade = form.value.classGrade
notificationData.filter.class = form.value.className
} else if (form.value.scope === 'MULTI_CLASS') {
notificationData.filter.classes = form.value.selectedClasses
} else if (form.value.scope === 'SPECIFIC_USERS') {
notificationData.filter.userIds = form.value.selectedUsers.map((user) => user.id)
}
// 发送通知
const result = await sendAdminNotification(notificationData)
if (result && result.success) {
success.value = `成功发送通知给 ${result.sentCount} 名用户`
// 3秒后自动隐藏成功提示
setTimeout(() => {
success.value = ''
}, 3000)
// 清空表单
form.value = {
title: '',
content: '',
scope: 'ALL',
grade: '',
classGrade: '',
className: '',
selectedClasses: [],
selectedUsers: []
}
multiClassForm.value = {
grade: '',
class: ''
}
// 清空用户搜索相关状态
userSearchQuery.value = ''
userSearchResults.value = []
showUserSearchResults.value = false
userSearchLoading.value = false
clearTimeout(userSearchTimeout)
} else {
throw new Error(result?.message || '发送通知失败')
}
} catch (err) {
error.value = err.message || '发送通知时发生错误'
console.error('发送通知错误:', err)
// 3秒后自动隐藏错误提示
setTimeout(() => {
error.value = ''
}, 3000)
} finally {
loading.value = false
}
}
onUnmounted(() => {
if (userSearchTimeout) {
clearTimeout(userSearchTimeout)
}
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
.fade-enter-active,
.fade-leave-active {
transition:
opacity 0.2s ease,
transform 0.2s ease;
}
.fade-enter-from {
opacity: 0;
transform: translateX(-10px);
}
.fade-leave-to {
opacity: 0;
transform: translateX(10px);
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<template>
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
<!-- 统计卡片网格 -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
<div
v-for="(stat, i) in statCards"
:key="i"
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 relative overflow-hidden group hover:border-zinc-700 transition-all shadow-lg shadow-black/20"
>
<div class="flex justify-between items-start mb-4">
<div
:class="[
'p-3 rounded-xl border',
stat.color === 'blue'
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
: stat.color === 'emerald'
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: stat.color === 'pink'
? 'bg-pink-500/10 text-pink-500 border-pink-500/20'
: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'
]"
>
<component :is="stat.icon" :size="24" />
</div>
<div
v-if="stat.trend"
:class="[
'flex items-center gap-1 text-[11px] font-bold',
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
]"
>
<TrendingDown v-if="stat.trendDown" :size="12" />
<TrendingUp v-else :size="12" />
{{ stat.trend }}
</div>
</div>
<div>
<p class="text-zinc-500 text-sm font-medium">{{ stat.label }}</p>
<h4 class="text-3xl font-bold tracking-tight text-zinc-100">{{ stat.value }}</h4>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 最近活动 -->
<div
class="lg:col-span-5 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
>
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<Activity :size="18" class="text-blue-500" /> 最近活动
</h3>
<button
class="p-2 text-zinc-500 hover:text-zinc-300 transition-colors"
:class="{ 'animate-spin': loadingActivities }"
@click="refreshActivities"
>
<RefreshCw :size="16" />
</button>
</div>
<div
class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1 min-h-[380px] max-h-[500px]"
>
<div
v-if="loadingActivities && recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
>
<RefreshCw :size="24" class="animate-spin" />
<span class="text-sm">加载中...</span>
</div>
<div
v-else-if="recentActivities.length === 0"
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
>
<Inbox :size="24" />
<span class="text-sm">暂无活动记录</span>
</div>
<template v-else>
<div
v-for="(activity, idx) in recentActivities"
:key="idx"
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-zinc-800/40 transition-all cursor-pointer group"
>
<div
:class="[
'shrink-0 w-10 h-10 rounded-xl flex items-center justify-center border shadow-sm',
getActivityStyle(activity.type).bg
]"
>
<component :is="getActivityStyle(activity.type).icon" :size="18" />
</div>
<div class="flex-1 min-w-0">
<h5
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
>
{{ activity.title }}
</h5>
<p class="text-xs text-zinc-500 truncate mt-1">{{ activity.description }}</p>
<div class="flex items-center gap-1.5 mt-2">
<Clock :size="10" class="text-zinc-600" />
<span class="text-[10px] text-zinc-600 font-medium uppercase tracking-wider">{{
formatTime(activity.createdAt)
}}</span>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- 系统状态 -->
<div
class="lg:col-span-4 bg-zinc-900/40 border border-zinc-800 rounded-xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
>
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
</h3>
<span
:class="[
'px-3 py-1 text-[10px] font-black uppercase rounded-full border',
systemStatus.online
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-red-500/10 text-red-500 border-red-500/20'
]"
>
{{ systemStatus.online ? '在线' : '离线' }}
</span>
</div>
<div class="p-6 space-y-6">
<div
v-for="(status, i) in statusItems"
:key="i"
class="flex items-center justify-between group"
>
<div class="flex items-center gap-3">
<div
:class="[
'w-1.5 h-1.5 rounded-full',
status.active
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.7)]'
: 'bg-zinc-600'
]"
/>
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
</div>
<span class="text-xs font-bold text-zinc-200">{{ status.value }}</span>
</div>
</div>
</div>
<!-- 快速操作 -->
<div
class="lg:col-span-3 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
>
<div class="px-6 py-5 border-b border-zinc-800">
<h3 class="text-lg font-bold flex items-center gap-2">
<Zap :size="18" class="text-yellow-500" /> 快速操作
</h3>
</div>
<div class="p-6 space-y-3">
<button
v-for="(action, i) in quickActions"
:key="i"
:class="[
'w-full flex items-center gap-3 px-5 py-4 rounded-lg border font-bold text-sm transition-all text-left group',
action.primary
? 'bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-900/20 hover:bg-blue-500'
: 'bg-zinc-950/40 border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
]"
@click="navigateTo(action.id)"
>
<component :is="action.icon" :size="18" />
{{ action.label }}
<ExternalLink v-if="action.primary" :size="14" class="ml-auto opacity-50" />
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import {
Music,
Users,
Calendar,
Heart,
TrendingDown,
TrendingUp,
Activity,
ShieldCheck,
RefreshCw,
Clock,
Zap,
Bell,
Ban,
ExternalLink,
Inbox
} from '@lucide/vue'
import packageJson from '~~/package.json'
const emit = defineEmits(['navigate'])
const systemVersion = ref(packageJson.version)
const { siteTitle } = useSiteConfig()
const displayName = computed(() => siteTitle.value || '站点')
const stats = ref({
totalSongs: 0,
totalUsers: 0,
todaySchedules: 0,
weeklyRequests: 0,
songsChange: 0,
usersChange: 0,
requestsChange: 0,
totalSchedules: 0,
currentSemester: '',
blacklistCount: 0
})
const recentActivities = ref([])
const loadingActivities = ref(false)
const systemStatus = ref({
online: true,
database: true,
api: true
})
// 统计卡片数据
const statCards = computed(() => [
{
label: '总歌曲数',
value: formatNumber(stats.value.totalSongs),
icon: Music,
color: 'blue',
trend: stats.value.songsChange !== 0 ? `${Math.abs(stats.value.songsChange)}%` : null,
trendDown: stats.value.songsChange < 0
},
{
label: '注册用户',
value: formatNumber(stats.value.totalUsers),
icon: Users,
color: 'emerald'
},
{
label: '今日排期',
value: formatNumber(stats.value.todaySchedules),
icon: Calendar,
color: 'zinc'
},
{
label: '本周点歌',
value: formatNumber(stats.value.weeklyRequests),
icon: Heart,
color: 'pink',
trend: stats.value.requestsChange !== 0 ? `${Math.abs(stats.value.requestsChange)}%` : null,
trendDown: stats.value.requestsChange < 0
}
])
// 系统状态项
const statusItems = computed(() => [
{
label: '数据库连接',
value: systemStatus.value.database ? '正常' : '异常',
active: systemStatus.value.database
},
{
label: 'API服务',
value: systemStatus.value.api ? '正常' : '异常',
active: systemStatus.value.api
},
{
label: '当前学期',
value: stats.value.currentSemester || '未设置',
active: !!stats.value.currentSemester
},
{
label: '黑名单项目',
value: `${stats.value.blacklistCount} 项`,
active: stats.value.blacklistCount >= 0
},
{ label: '站点版本', value: `${displayName.value} v${systemVersion.value}`, active: true }
])
// 快速操作
const quickActions = [
{ label: '管理排期', icon: Calendar, id: 'schedule', primary: true },
{ label: '用户管理', icon: Users, id: 'users' },
{ label: '发送通知', icon: Bell, id: 'notifications' },
{ label: '黑名单管理', icon: Ban, id: 'blacklist' }
]
const formatNumber = (num) => {
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
return num.toString()
}
const getActivityStyle = (type) => {
const styles = {
song: { icon: Music, bg: 'bg-purple-500/10 text-purple-500 border-purple-500/20' },
user: { icon: Users, bg: 'bg-pink-500/10 text-pink-500 border-pink-500/20' },
schedule: { icon: Calendar, bg: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
}
return styles[type] || { icon: Activity, bg: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20' }
}
const loadStats = async () => {
try {
const response = await $fetch('/api/admin/stats', {
...useAuth().getAuthConfig()
})
stats.value = response
} catch (error) {
console.error('加载统计数据失败:', error)
}
}
const loadRecentActivities = async () => {
loadingActivities.value = true
try {
const response = await $fetch('/api/admin/activities', {
...useAuth().getAuthConfig()
})
recentActivities.value = response.activities || []
} catch (error) {
console.error('加载活动记录失败:', error)
recentActivities.value = []
} finally {
loadingActivities.value = false
}
}
const refreshActivities = () => {
loadRecentActivities()
}
const formatTime = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
return `${Math.floor(diff / 86400000)}天前`
}
const navigateTo = (tab) => {
emit('navigate', tab)
}
onMounted(() => {
loadStats()
loadRecentActivities()
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
+686
View File
@@ -0,0 +1,686 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">播出时段管理</h2>
<p class="text-xs text-zinc-500 mt-1">
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
</p>
</div>
<div class="flex items-center gap-3">
<div
class="bg-zinc-900/40 border border-zinc-800 rounded-xl px-4 py-2 flex items-center gap-3"
>
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>启用播出时段选择</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
]"
@click="toggleGlobalEnabled"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
enablePlayTimeSelection ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
<button
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="openAddForm"
>
<Plus :size="14" /> 添加播出时段
</button>
</div>
</div>
<!-- 信息卡片 -->
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
<Info class="text-blue-500 shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-300">关于时段限制</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
</p>
</div>
</div>
<!-- 加载状态 -->
<div
v-if="loading"
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
>
<div
class="w-10 h-10 border-4 border-blue-600/20 border-t-blue-600 rounded-full animate-spin"
/>
<p class="text-xs font-black text-zinc-500 uppercase tracking-widest">加载中...</p>
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="bg-red-500/5 border border-red-500/10 rounded-xl p-5 flex items-start gap-4"
>
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-300">获取数据失败</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">{{ error }}</p>
</div>
</div>
<!-- 空状态 -->
<div
v-else-if="playTimes.length === 0"
class="flex flex-col items-center justify-center py-20 space-y-6 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
>
<div class="p-6 rounded-xl bg-zinc-950 border border-zinc-800 text-zinc-700">
<Clock :size="48" />
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-bold text-zinc-100">暂无播出时段</h3>
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
</div>
</div>
<!-- 时段网格 -->
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
<TransitionGroup name="list">
<div
v-for="playTime in playTimes"
:key="playTime.id"
:class="[
'group relative bg-zinc-900/30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-black/40',
playTime.enabled ? 'border-zinc-800/80' : 'border-zinc-800/40 opacity-60'
]"
>
<div class="flex items-start justify-between mb-6">
<div
:class="[
'p-3 rounded-xl bg-zinc-950 border border-zinc-800 transition-all',
playTime.enabled ? 'text-blue-500 border-blue-500/20' : 'text-zinc-700'
]"
>
<Clock :size="20" />
</div>
<div class="flex items-center gap-2">
<span
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
playTime.enabled
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
]"
>
{{ playTime.enabled ? '已启用' : '已禁用' }}
</span>
<button class="p-1.5 text-zinc-700 hover:text-zinc-400">
<MoreVertical :size="14" />
</button>
</div>
</div>
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors"
>
{{ playTime.name }}
</h4>
<div
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
>
<span class="text-xl">{{ playTime.startTime || '不限' }}</span>
<div class="w-4 h-[2px] bg-zinc-800" />
<span class="text-xl">{{ playTime.endTime || '不限' }}</span>
</div>
</div>
<p class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2">
{{ playTime.description || '暂无详细描述...' }}
</p>
</div>
<div class="mt-8 pt-6 border-t border-zinc-800/50 flex items-center justify-between">
<div class="flex gap-2">
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
@click="editPlayTime(playTime)"
>
<Edit2 :size="14" />
</button>
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
@click="confirmDelete(playTime)"
>
<Trash2 :size="14" />
</button>
</div>
<button
:class="[
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
playTime.enabled
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="togglePlayTimeStatus(playTime)"
>
<Power :size="12" />
{{ playTime.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
</TransitionGroup>
</div>
<!-- 添加/编辑弹窗 -->
<Transition name="modal">
<div
v-if="showAddForm || editingPlayTime"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
>
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
<div
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-black text-zinc-100 tracking-tight">
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
</h3>
<button
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
@click="cancelForm"
>
<X :size="20" />
</button>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段名称</label
>
<input
v-model="formData.name"
type="text"
placeholder="例如: 午间广播"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>开始时间 (可选)</label
>
<div class="relative">
<input
v-model="formData.startTime"
type="time"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
:size="14"
/>
</div>
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制开始时间</p>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>结束时间 (可选)</label
>
<div class="relative">
<input
v-model="formData.endTime"
type="time"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
:size="14"
/>
</div>
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制结束时间</p>
</div>
</div>
<div class="space-y-2">
<label
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 flex items-center gap-2"
>
<AlignLeft :size="10" /> 描述 (可选)
</label>
<textarea
v-model="formData.description"
placeholder="请输入时段描述信息..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
/>
</div>
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="formData.enabled"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>启用此播出时段</span
>
</label>
<div
v-if="formError"
class="bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-[11px] text-red-400"
>
{{ formError }}
</div>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
@click="cancelForm"
>
取消
</button>
<button
:disabled="formSubmitting"
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
@click="savePlayTime"
>
{{ formSubmitting ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 删除确认弹窗 -->
<Transition name="modal">
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
<div
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
@click="showDeleteConfirm = false"
/>
<div
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex flex-col items-center py-4 space-y-6">
<div
class="w-16 h-16 rounded-xl bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/10 shadow-xl shadow-red-900/5"
>
<AlertCircle :size="32" />
</div>
<div class="text-center space-y-2 px-4">
<h4 class="text-lg font-bold text-zinc-100">
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
此操作不可恢复,相关的歌曲点播和排期的时段设置将受影响或被清除。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 text-zinc-500 text-xs font-black rounded-lg transition-all hover:bg-zinc-800"
@click="showDeleteConfirm = false"
>
取消
</button>
<button
:disabled="deleteInProgress"
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95"
@click="deletePlayTime"
>
{{ deleteInProgress ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script lang="ts" setup>
import { onMounted, reactive, ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
import type { PlayTime } from '~~/types'
import {
Plus,
Clock,
Edit2,
Trash2,
MoreVertical,
Power,
Info,
AlertCircle,
X,
AlignLeft
} from '@lucide/vue'
const { getAuthConfig, isAdmin } = useAuth()
const { showToast: showNotification } = useToast()
const playTimes = ref<PlayTime[]>([])
const loading = ref(false)
const error = ref('')
const showAddForm = ref(false)
const editingPlayTime = ref<PlayTime | null>(null)
const playTimeToDelete = ref<PlayTime | null>(null)
const showDeleteConfirm = ref(false)
const formSubmitting = ref(false)
const deleteInProgress = ref(false)
const formError = ref('')
const enablePlayTimeSelection = ref(false)
// 表单数据
const formData = reactive({
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
// 初始化
onMounted(async () => {
await fetchPlayTimes()
await fetchSystemSettings()
})
// 获取播出时段列表
const fetchPlayTimes = async () => {
if (!isAdmin.value) {
error.value = '只有管理员才能管理播出时段'
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/play-times', {
...authConfig,
query: { t: Date.now() }
})
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
// 首先按启用状态排序
if (a.enabled !== b.enabled) {
return a.enabled ? -1 : 1 // 启用的排在前面
}
// 然后按是否有时间排序
const aHasTime = !!(a.startTime || a.endTime)
const bHasTime = !!(b.startTime || b.endTime)
if (aHasTime !== bHasTime) {
return aHasTime ? -1 : 1 // 有时间的排在前面
}
// 如果都有时间,按开始时间排序
if (aHasTime && bHasTime) {
if (a.startTime && b.startTime) {
return a.startTime.localeCompare(b.startTime)
} else if (a.startTime) {
return -1
} else if (b.startTime) {
return 1
}
}
// 最后按名称排序
return a.name.localeCompare(b.name)
})
} catch (err: any) {
error.value = err.message || '获取播出时段失败'
} finally {
loading.value = false
}
}
// 获取系统设置
const fetchSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/system-settings', {
...authConfig
})
enablePlayTimeSelection.value = data.enablePlayTimeSelection
} catch (err: any) {
console.error('获取系统设置失败:', err.message)
}
}
// 切换全局启用状态
const toggleGlobalEnabled = async () => {
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
await updateSystemSettings()
}
// 更新系统设置
const updateSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
await $fetch('/api/admin/system-settings', {
method: 'POST',
body: {
enablePlayTimeSelection: enablePlayTimeSelection.value
},
...authConfig
})
showNotification('系统设置已更新', 'success')
} catch (err: any) {
error.value = err.message || '更新系统设置失败'
showNotification(error.value, 'error')
// 如果失败,恢复状态
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
}
}
// 打开添加表单
const openAddForm = () => {
showAddForm.value = true
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
}
// 编辑播出时段
const editPlayTime = (playTime: PlayTime) => {
editingPlayTime.value = playTime
Object.assign(formData, {
id: playTime.id,
name: playTime.name,
startTime: playTime.startTime || '',
endTime: playTime.endTime || '',
description: playTime.description || '',
enabled: playTime.enabled
})
}
// 切换播出时段状态
const togglePlayTimeStatus = async (playTime: PlayTime) => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
await $fetch(`/api/admin/play-times/${playTime.id}`, {
method: 'PATCH',
body: {
enabled: !playTime.enabled
},
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
showNotification(playTime.enabled ? '播出时段已禁用' : '播出时段已启用', 'success')
} catch (err: any) {
error.value = err.message || '更新播出时段状态失败'
showNotification(error.value, 'error')
}
}
// 确认删除
const confirmDelete = (playTime: PlayTime) => {
playTimeToDelete.value = playTime
showDeleteConfirm.value = true
}
// 删除播出时段
const deletePlayTime = async () => {
if (!playTimeToDelete.value || !isAdmin.value) return
deleteInProgress.value = true
try {
const authConfig = getAuthConfig()
await $fetch(`/api/admin/play-times/${playTimeToDelete.value.id}`, {
method: 'DELETE',
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
showDeleteConfirm.value = false
playTimeToDelete.value = null
showNotification('播出时段已删除', 'success')
} catch (err: any) {
error.value = err.message || '删除播出时段失败'
showNotification(error.value, 'error')
} finally {
deleteInProgress.value = false
}
}
// 保存播出时段
const savePlayTime = async () => {
formError.value = ''
// 时间验证(仅当两个时间都有填写时才进行比较)
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
formError.value = '开始时间必须早于结束时间'
return
}
// 至少要有名称
if (!formData.name.trim()) {
formError.value = '时段名称不能为空'
return
}
// 检查名称是否重复
const isUpdate = !!editingPlayTime.value
const nameExists = playTimes.value.some(
(pt) =>
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
(!isUpdate || pt.id !== formData.id)
)
if (nameExists) {
formError.value = '播出时段名称已存在,请使用其他名称'
return
}
formSubmitting.value = true
try {
const authConfig = getAuthConfig()
await $fetch(isUpdate ? `/api/admin/play-times/${formData.id}` : '/api/admin/play-times', {
method: isUpdate ? 'PUT' : 'POST',
body: {
name: formData.name.trim(),
startTime: formData.startTime || null,
endTime: formData.endTime || null,
description: formData.description || null,
enabled: formData.enabled
},
...authConfig
})
// 更新本地数据
await fetchPlayTimes()
cancelForm()
showNotification(isUpdate ? '播出时段已更新' : '播出时段已创建', 'success')
} catch (err: any) {
formError.value = err.message || '保存播出时段失败'
showNotification(formError.value, 'error')
} finally {
formSubmitting.value = false
}
}
// 取消表单
const cancelForm = () => {
showAddForm.value = false
editingPlayTime.value = null
formError.value = ''
// 重置表单数据
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true
})
}
</script>
<style scoped>
.list-enter-active,
.list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: scale(0.95);
}
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .relative,
.modal-leave-active .relative {
transition: all 0.3s ease;
}
.modal-enter-from .relative,
.modal-leave-to .relative {
transform: scale(0.95);
opacity: 0;
}
</style>
@@ -0,0 +1,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>
+851
View File
@@ -0,0 +1,851 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
<!-- 顶部标题和全局开关 -->
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
<div class="space-y-1">
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">投稿开放管理</h2>
<p class="text-xs text-zinc-500">管理全校师生点歌投稿的开放时间、限制数量及全局状态控制</p>
</div>
<div
class="flex flex-wrap items-center gap-4 bg-zinc-900/40 border border-zinc-800 p-2 rounded-3xl"
>
<div class="flex items-center gap-3 px-3 border-r border-zinc-800/50 mr-1">
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>当前状态</span
>
<span
:class="[
'px-2 py-0.5 rounded text-[10px] font-black uppercase transition-all',
hitRequestTime ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'
]"
>
{{ hitRequestTime ? '已开放' : '已关闭' }}
</span>
</div>
<div class="flex items-center gap-3 px-2">
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
>启用投稿</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enableRequest ? 'bg-blue-600' : 'bg-zinc-800'
]"
@click="toggleGlobalRequest"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
enableRequest ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
<div class="flex items-center gap-3 px-2 border-l border-zinc-800/50">
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
>启用时段限制</span
>
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enableRequestTimeLimitation ? 'bg-purple-600' : 'bg-zinc-800'
]"
@click="toggleTimeLimitation"
>
<div
:class="[
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
enableRequestTimeLimitation ? 'left-6' : 'left-1'
]"
/>
</button>
</div>
</div>
</div>
<!-- 主要内容区域 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- 添加时段按钮卡片 -->
<button
class="group relative h-full min-h-[220px] bg-zinc-900/20 border-2 border-dashed border-zinc-800 rounded-3xl flex flex-col items-center justify-center gap-4 hover:bg-blue-600/5 hover:border-blue-500/40 transition-all active:scale-95"
@click="showAddForm = true"
>
<div
class="w-14 h-14 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 group-hover:text-blue-500 group-hover:bg-blue-600/10 group-hover:border-blue-500/20 transition-all"
>
<Plus :size="28" />
</div>
<div class="text-center">
<h4
class="text-sm font-black text-zinc-400 group-hover:text-blue-400 transition-colors uppercase tracking-widest"
>
添加投稿时段
</h4>
<p class="text-[10px] text-zinc-600 mt-1">创建一个新的投稿开放周期</p>
</div>
</button>
<!-- 时段列表 -->
<TransitionGroup
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-for="slot in requestTimes"
:key="slot.id"
:class="[
'group bg-zinc-900/40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-black/40',
slot.enabled && !slot.past ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60'
]"
>
<div class="flex items-start justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 text-zinc-400 transition-colors',
slot.enabled && !slot.past
? 'text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/10'
: ''
]"
>
<Calendar :size="22" />
</div>
<div class="flex items-center gap-2">
<span
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
slot.past
? 'bg-red-500/10 text-red-500 border-red-500/20'
: slot.enabled
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
]"
>
{{ slot.past ? '已过期' : slot.enabled ? '已启用' : '已禁用' }}
</span>
</div>
</div>
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-zinc-100 flex items-baseline gap-2 group-hover:text-blue-400 transition-colors"
>
{{ slot.name }}
<span class="text-xs font-bold text-zinc-600 tracking-tight"
>({{ slot.accepted }}/{{ slot.expected || '∞' }})</span
>
</h4>
<div class="mt-3 space-y-2">
<div class="flex items-center gap-2 text-zinc-500 font-bold">
<Clock :size="12" class="text-zinc-700" />
<span class="text-[10px] uppercase tracking-tighter truncate">{{
slot.startTime || '不限'
}}</span>
</div>
<div class="flex items-center gap-2 text-zinc-500 font-bold">
<div class="w-3 h-[2px] bg-zinc-800 ml-1.5" />
<span class="text-[10px] uppercase tracking-tighter truncate">{{
slot.endTime || '不限'
}}</span>
</div>
</div>
</div>
<p
class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2 italic"
>
{{ slot.description || '暂无详细描述...' }}
</p>
<!-- 投稿进度条 -->
<div class="space-y-1.5">
<div
class="flex justify-between text-[9px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
>
<span>投稿进度</span>
<span
>{{
slot.expected > 0
? Math.min(100, Math.round((slot.accepted / slot.expected) * 100))
: 0
}}%</span
>
</div>
<div
class="h-1.5 w-full bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50"
>
<div
class="h-full transition-all duration-500"
:style="{
width: `${slot.expected > 0 ? Math.min(100, (slot.accepted / slot.expected) * 100) : 0}%`
}"
:class="
slot.expected > 0 && slot.accepted >= slot.expected
? 'bg-rose-500'
: 'bg-blue-500'
"
/>
</div>
</div>
</div>
<div class="pt-6 border-t border-zinc-800/50 flex items-center justify-between">
<div class="flex gap-2">
<button
v-show="!slot.past"
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
@click="editRequestTime(slot)"
>
<Edit2 :size="14" />
</button>
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
@click="confirmDelete(slot)"
>
<Trash2 :size="14" />
</button>
</div>
<button
v-show="!slot.past"
:class="[
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
slot.enabled
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="toggleRequestTimeStatus(slot)"
>
<Power :size="12" />
{{ slot.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
</TransitionGroup>
</div>
<!-- 统计概览 -->
<div
class="bg-zinc-900/20 border border-zinc-800 rounded-3xl p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
>
<div v-for="(stat, i) in stats" :key="i" class="flex items-center gap-4 group">
<div
:class="[
'w-12 h-12 rounded-2xl bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-500 transition-colors',
`group-hover:text-${stat.color}-500`
]"
>
<component :is="stat.icon" :size="20" />
</div>
<div>
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
{{ stat.label }}
</p>
<h5 class="text-xl font-black text-zinc-200">{{ stat.value }}</h5>
</div>
</div>
</div>
<!-- 添加/编辑模态框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showAddForm || editingRequestTime"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
@click.self="cancelForm"
>
<div
class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
>
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="cancelForm">
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-6 max-h-[70vh] overflow-y-auto">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段名称</label
>
<input
v-model="formData.name"
type="text"
placeholder="例如: 2025冬季常规点歌"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>开始日期时间</label
>
<input
v-model="formData.startTime"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>结束日期时间</label
>
<input
v-model="formData.endTime"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>计划投稿数</label
>
<div class="relative group">
<input
v-model="formData.expected"
type="number"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
<div
class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-zinc-600 font-bold text-[10px]"
>
<Hash :size="12" />
首歌曲
</div>
</div>
<p class="text-[9px] text-zinc-600 px-1">填 0 表示不限制投稿总数</p>
</div>
<div class="space-y-2 flex flex-col justify-center pt-2">
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="formData.enabled"
type="checkbox"
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>启用此投稿时段</span
>
<p class="text-[9px] text-zinc-600 font-medium">
只有启用的时段才会根据时间限制生效
</p>
</div>
</label>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段描述 (可选)</label
>
<textarea
v-model="formData.description"
placeholder="请输入针对此开放周期的特殊规则或介绍信息..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
/>
</div>
<div
v-if="formError"
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-500 text-xs font-bold"
>
<AlertCircle :size="16" />
{{ formError }}
</div>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
@click="cancelForm"
>
取消
</button>
<button
:disabled="formSubmitting"
class="px-10 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 disabled:opacity-50"
@click="saveRequestTime"
>
{{ formSubmitting ? '保存中...' : '保存设置' }}
</button>
</div>
</div>
</div>
</Transition>
<!-- 删除确认模态框 -->
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
@click.self="showDeleteConfirm = false"
>
<div
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden p-8"
>
<div class="flex flex-col items-center space-y-6">
<div
class="w-16 h-16 rounded-[2rem] bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-xl shadow-red-900/10"
>
<Trash2 :size="28" />
</div>
<div class="text-center space-y-2 px-4">
<h4 class="text-lg font-bold text-zinc-100">
确定要彻底移除 "{{ RequestTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
删除时段后,原本属于此周期的投稿关联记录可能失效。建议先尝试
<span class="text-zinc-300 font-bold">禁用</span> 该时段。
</p>
</div>
<div class="flex gap-3 w-full pt-4">
<button
class="flex-1 px-4 py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-2xl transition-all"
@click="showDeleteConfirm = false"
>
保留时段
</button>
<button
:disabled="deleteInProgress"
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-2xl shadow-xl shadow-red-900/20 transition-all active:scale-95 disabled:opacity-50"
@click="deleteRequestTime"
>
{{ deleteInProgress ? '删除中...' : '确认删除' }}
</button>
</div>
</div>
</div>
</div>
</Transition>
<!-- 说明部分 -->
<div class="flex flex-col md:flex-row gap-6">
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
<h4
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<CheckCircle2 :size="14" class="text-emerald-500" /> 逻辑优先级说明
</h4>
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
<li class="flex gap-2">
<span class="text-zinc-700 font-black">1.</span>
<span
><span class="text-zinc-300">全局开关</span>
具有最高优先级,关闭后所有投稿均不可用。</span
>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">2.</span>
<span
>开启 <span class="text-zinc-300">时段限制</span> 后,系统会检查当前时间是否处于任一
<span class="text-zinc-300">已启用</span> 且未达到
<span class="text-zinc-300">计划数</span> 的时段内。</span
>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">3.</span>
<span>若关闭时段限制,则全天候均可投稿(只要全局开关开启)。</span>
</li>
</ul>
</div>
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
<h4
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<XCircle :size="14" class="text-rose-500" /> 注意事项
</h4>
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
<li class="flex gap-2">
<span class="text-zinc-700 font-black">•</span>
<span>时段重叠时,系统将合并开放范围。</span>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">•</span>
<span>计划投稿数设置为 0 时,系统将忽略数量上限检查。</span>
</li>
<li class="flex gap-2">
<span class="text-zinc-700 font-black">•</span>
<span>过期的时段将自动显示为“已过期”,不可编辑或重新启用。</span>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
import { useAuth } from '~/composables/useAuth'
import type { RequestTime } from '~~/types'
import {
Plus,
Calendar,
Clock,
Edit2,
Trash2,
Power,
AlertCircle,
Hash,
CheckCircle2,
XCircle,
X,
BarChart3,
Filter
} from '@lucide/vue'
const { getAuthConfig, isAdmin } = useAuth()
const requestTimes = ref<RequestTime[]>([])
const loading = ref(false)
const error = ref('')
const showAddForm = ref(false)
const editingRequestTime = ref<RequestTime | null>(null)
const RequestTimeToDelete = ref<RequestTime | null>(null)
const showDeleteConfirm = ref(false)
const formSubmitting = ref(false)
const deleteInProgress = ref(false)
const formError = ref('')
const enableRequestTimeLimitation = ref(false)
const hitRequestTime = ref(false)
const enableRequest = ref(true)
let refreshInterval: any = null
const formData = reactive({
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true,
expected: 0
})
// 统计数据
const stats = computed(() => [
{
label: '活跃时段',
value: requestTimes.value.filter((s) => s.enabled && !s.past).length.toString(),
icon: Clock,
color: 'blue'
},
{
label: '累计已接收',
value: requestTimes.value.reduce((acc, s) => acc + s.accepted, 0).toString(),
icon: BarChart3,
color: 'emerald'
},
{
label: '计划总容量',
value: requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0) || '不限',
icon: Hash,
color: 'purple'
},
{
label: '剩余总名额',
value: (() => {
const totalExpected = requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0)
const totalAccepted = requestTimes.value.reduce((acc, s) => acc + s.accepted, 0)
return totalExpected > 0 ? Math.max(0, totalExpected - totalAccepted).toString() : '不限'
})(),
icon: Filter,
color: 'amber'
}
])
onMounted(async () => {
await fetchRequestTimes()
await fetchSystemSettings()
await fetchRequestTimeHit()
// 每 30 秒自动刷新一次状态,以确保时间同步
refreshInterval = setInterval(fetchRequestTimeHit, 30000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
})
const fetchRequestTimes = async () => {
if (!isAdmin.value) {
error.value = '只有管理员才能管理投稿开放时段'
return
}
loading.value = true
error.value = ''
try {
const authConfig = getAuthConfig()
const data = await $fetch('/api/admin/request-times', {
...authConfig,
query: { t: Date.now() }
})
requestTimes.value = data.sort((a: RequestTime, b: RequestTime) => {
if (a.past !== b.past) return a.past ? 1 : -1
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
const aHasTime = !!(a.startTime || a.endTime)
const bHasTime = !!(b.startTime || b.endTime)
if (aHasTime !== bHasTime) return aHasTime ? -1 : 1
if (aHasTime && bHasTime) {
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime)
else if (a.startTime) return -1
else if (b.startTime) return 1
}
return a.name.localeCompare(b.name)
})
} catch (err: any) {
error.value = err.message || '获取投稿开放时段失败'
} finally {
loading.value = false
}
}
const fetchSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/system-settings?t=${Date.now()}`, {
headers: { 'Content-Type': 'application/json' },
...authConfig
})
if (!response.ok) return
const data = await response.json()
enableRequestTimeLimitation.value = data.enableRequestTimeLimitation
enableRequest.value = !data.forceBlockAllRequests
} catch (err: any) {
console.error('获取系统设置失败:', err.message)
}
}
const fetchRequestTimeHit = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/request-times?t=${Date.now()}`, {
headers: { 'Content-Type': 'application/json' },
...authConfig
})
if (!response.ok) return
const data = await response.json()
hitRequestTime.value = data.hit
} catch (err: any) {
console.error('获取投稿开放状态失败:', err.message)
}
}
const toggleGlobalRequest = async () => {
enableRequest.value = !enableRequest.value
await updateSystemSettings()
await fetchRequestTimeHit()
}
const toggleTimeLimitation = async () => {
enableRequestTimeLimitation.value = !enableRequestTimeLimitation.value
await updateSystemSettings()
await fetchRequestTimeHit()
}
const updateSystemSettings = async () => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enableRequestTimeLimitation: enableRequestTimeLimitation.value,
forceBlockAllRequests: !enableRequest.value
}),
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
const errMsg = errorData.message || `更新系统设置失败: ${response.status}`
if (window.$showNotification) window.$showNotification(errMsg, 'error')
throw new Error(errMsg)
}
} catch (err: any) {
error.value = err.message || '更新系统设置失败'
if (window.$showNotification) window.$showNotification(error.value, 'error')
}
}
const editRequestTime = (RequestTime: RequestTime) => {
editingRequestTime.value = RequestTime
Object.assign(formData, {
id: RequestTime.id,
name: RequestTime.name,
startTime: RequestTime.startTime,
endTime: RequestTime.endTime,
description: RequestTime.description || '',
enabled: RequestTime.enabled,
expected: RequestTime.expected || 0
})
}
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
if (!isAdmin.value) return
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/request-times/${RequestTime.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: !RequestTime.enabled }),
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `更新投稿开放时段状态失败: ${response.status}`)
}
await fetchRequestTimes()
await fetchRequestTimeHit()
} catch (err: any) {
error.value = err.message || '更新投稿开放时段状态失败'
}
}
const confirmDelete = (RequestTime: RequestTime) => {
RequestTimeToDelete.value = RequestTime
showDeleteConfirm.value = true
}
const deleteRequestTime = async () => {
if (!RequestTimeToDelete.value || !isAdmin.value) return
deleteInProgress.value = true
try {
const authConfig = getAuthConfig()
const response = await fetch(`/api/admin/request-times/${RequestTimeToDelete.value.id}`, {
method: 'DELETE',
...authConfig
})
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.message || `删除投稿开放时段失败: ${response.status}`)
}
await fetchRequestTimes()
await fetchRequestTimeHit()
showDeleteConfirm.value = false
RequestTimeToDelete.value = null
} catch (err: any) {
error.value = err.message || '删除投稿开放时段失败'
} finally {
deleteInProgress.value = false
}
}
const saveRequestTime = async () => {
formError.value = ''
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
formError.value = '开始时间必须早于结束时间'
return
}
if (!formData.name.trim()) {
formError.value = '时段名称不能为空'
return
}
const isUpdate = !!editingRequestTime.value
const nameExists = requestTimes.value.some(
(pt) =>
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
(!isUpdate || pt.id !== formData.id)
)
if (nameExists) {
formError.value = '投稿开放时段名称已存在,请使用其他名称'
return
}
formSubmitting.value = true
try {
const authConfig = getAuthConfig()
const response = await fetch(
isUpdate ? `/api/admin/request-times/${formData.id}` : '/api/admin/request-times',
{
method: isUpdate ? 'PUT' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: formData.name.trim(),
startTime: formData.startTime || null,
endTime: formData.endTime || null,
description: formData.description || null,
enabled: formData.enabled,
expected: formData.expected || 0
}),
...authConfig
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(
errorData.message || `${isUpdate ? '更新' : '创建'}投稿开放时段失败: ${response.status}`
)
}
await fetchRequestTimes()
await fetchRequestTimeHit()
cancelForm()
} catch (err: any) {
formError.value = err.message || '保存投稿开放时段失败'
} finally {
formSubmitting.value = false
}
}
const cancelForm = () => {
showAddForm.value = false
editingRequestTime.value = null
formError.value = ''
Object.assign(formData, {
id: 0,
name: '',
startTime: '',
endTime: '',
description: '',
enabled: true,
expected: 0
})
}
</script>
<style scoped>
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
+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>
+172
View File
@@ -0,0 +1,172 @@
<template>
<div
class="backdrop-blur-md p-6 rounded-xl border border-white/10 bg-slate-800/70 shadow-2xl max-w-[400px] mx-auto text-zinc-100 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_rgba(0,0,0,0.2)]"
>
<h3 class="mb-6 pb-2 border-b border-white/10 text-zinc-100 font-bold text-lg">
为歌曲 "{{ song?.title }}" 创建排期
</h3>
<form @submit.prevent="handleSubmit">
<div class="mb-4">
<label class="block mb-2 font-medium text-zinc-100" for="playDate">播放日期</label>
<input
id="playDate"
v-model="playDate"
class="w-full p-3 border border-white/10 rounded-lg text-base bg-[#0f172a99] text-zinc-100 outline-none transition-colors duration-150 focus:border-indigo-500 focus:shadow-[0_0_0_2px_rgba(99,102,241,0.25)]"
required
type="date"
>
</div>
<!-- 播出时段选择 -->
<div v-if="playTimeEnabled" class="mb-4">
<CustomSelect
v-model="playTimeId"
:options="playTimeOptions"
label="播出时段"
placeholder="未指定"
class-name="w-full"
/>
<div
v-if="song?.preferredPlayTime"
class="mt-3 p-3 bg-white/5 rounded-lg text-sm flex items-start gap-2"
>
<Icon class="text-base text-yellow-400" name="lightbulb" size="16" />
<div>
用户期望的播出时段:
<span class="font-medium text-indigo-300">
{{ song.preferredPlayTime.name }}
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
({{ formatPlayTimeRange(song.preferredPlayTime) }})
</template>
</span>
</div>
</div>
</div>
<div v-if="error" class="p-3 mt-4 bg-red-500/10 text-red-300 rounded-lg">
{{ error }}
</div>
<div class="flex justify-between gap-4 mt-6">
<button
class="flex-1 p-3 border border-white/10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-white/10 text-zinc-100 hover:bg-white/15"
type="button"
@click="$emit('cancel')"
>
取消
</button>
<button
:disabled="loading"
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-indigo-600 text-white hover:bg-indigo-700 hover:-translate-y-px disabled:bg-indigo-500/50 disabled:cursor-not-allowed disabled:transform-none"
type="submit"
>
{{ loading ? '创建中...' : '创建排期' }}
</button>
</div>
</form>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import { useSongs } from '~/composables/useSongs'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const props = defineProps({
song: {
type: Object,
required: true
},
loading: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['schedule', 'cancel'])
const playDate = ref('')
const playTimeId = ref('')
const error = ref('')
const playTimes = ref([])
const { playTimeEnabled } = useSongs()
// 转换播出时段为 CustomSelect 选项格式
const playTimeOptions = computed(() => {
const options = [{ label: '未指定', value: '' }]
if (playTimes.value && playTimes.value.length > 0) {
playTimes.value.forEach((pt) => {
options.push({
label: `${pt.name} (${pt.startTime} - ${pt.endTime})`,
value: pt.id
})
})
}
return options
})
// 初始化
onMounted(async () => {
await fetchPlayTimes()
// 如果歌曲有期望的播出时段,默认选择该时段
if (props.song?.preferredPlayTimeId) {
playTimeId.value = props.song.preferredPlayTimeId
}
})
// 获取播出时段
const fetchPlayTimes = async () => {
try {
// 使用useSongs中的方法获取播放时段
await useSongs().fetchPlayTimes()
const response = await fetch('/api/admin/play-times')
if (response.ok) {
const data = await response.json()
// 只显示启用的播放时段
playTimes.value = data.filter((pt) => pt.enabled)
}
} catch (err) {
console.error('获取播出时段失败:', err)
}
}
// 格式化播出时段时间范围
const formatPlayTimeRange = (playTime) => {
if (!playTime) return ''
if (playTime.startTime && playTime.endTime) {
return `${playTime.startTime} - ${playTime.endTime}`
} else if (playTime.startTime) {
return `${playTime.startTime} 开始`
} else if (playTime.endTime) {
return `${playTime.endTime} 结束`
}
return '不限时间'
}
const handleSubmit = () => {
error.value = ''
if (!playDate.value) {
error.value = '请选择播放日期'
return
}
const selectedDate = new Date(playDate.value)
// 播出时段ID需要转换为数字或null
const schedulePlayTimeId = playTimeId.value ? parseInt(playTimeId.value) : null
emit('schedule', {
songId: props.song.id,
playDate: selectedDate,
playTimeId: schedulePlayTimeId
})
}
</script>
+308
View File
@@ -0,0 +1,308 @@
<template>
<div class="schedule-item-print">
<div class="item-content">
<!-- 序号 -->
<div v-if="settings.showSequence" class="sequence-number">
{{ schedule.sequence || 1 }}
</div>
<!-- 歌曲封面 -->
<div v-if="settings.showCover" class="cover-section">
<img
v-if="schedule.song.cover"
:alt="schedule.song.title"
:src="convertToHttps(schedule.song.cover)"
class="song-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div class="cover-placeholder" :class="{ 'show': !schedule.song.cover }">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6" />
<path d="m21 12-6-3-6 3-6-3" />
</svg>
</div>
</div>
<!-- 歌曲信息 -->
<div class="song-info">
<div v-if="settings.showTitle" class="song-title">
{{ schedule.song.title }}
<!-- 重播标识 -->
<span v-if="schedule.song.replayRequestCount > 0" class="replay-badge-print"> 重播 </span>
</div>
<div v-if="settings.showArtist" class="song-artist">
{{ schedule.song.artist }}
</div>
</div>
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
<div v-if="settings.showRequester" class="requester-info">
<span class="label">投稿人:</span>
<span class="value">
{{ schedule.song.requester }}
<span v-if="schedule.song.collaborators && schedule.song.collaborators.length > 0">
& {{ schedule.song.collaborators.map((c) => c.displayName || c.name).join(' & ') }}
</span>
</span>
</div>
<!-- 人数信息 -->
<div v-if="settings.showVotes" class="votes-info">
<span v-if="schedule.song.replayRequestCount > 0" class="label">申请重播:</span>
<span v-else class="label">热度:</span>
<span class="value">{{
schedule.song.replayRequestCount > 0
? schedule.song.replayRequestCount + '人'
: schedule.song.voteCount || 0
}}</span>
</div>
</div>
</div>
</template>
<script setup>
import { defineProps } from 'vue'
import { convertToHttps } from '~/utils/url'
// 定义props
defineProps({
schedule: {
type: Object,
required: true
},
settings: {
type: Object,
required: true
}
})
// 处理图片加载错误
const handleImageError = (event) => {
event.target.style.display = 'none'
event.target.nextElementSibling?.classList.add('show')
}
</script>
<style scoped>
.schedule-item-print {
display: flex;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid #e5e5e5;
page-break-inside: avoid;
}
.item-content {
display: flex;
align-items: center;
width: 100%;
gap: 12px;
}
.sequence-number {
width: 30px;
height: 30px;
background: #f0f0f0;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 14px;
color: #333;
flex-shrink: 0;
}
.cover-section {
width: 40px;
height: 40px;
flex-shrink: 0;
}
.song-cover {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
}
.cover-placeholder {
width: 100%;
height: 100%;
background: #f5f5f5;
border-radius: 4px;
display: none;
align-items: center;
justify-content: center;
color: #999;
}
.cover-placeholder.show {
display: flex;
}
.cover-placeholder svg {
width: 20px;
height: 20px;
}
.song-info {
flex: 1;
min-width: 0;
}
.song-title {
font-weight: bold;
font-size: 16px;
color: #333;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: flex;
align-items: center;
gap: 4px;
}
/* 打印用重播标识 */
.replay-badge-print {
display: inline-block;
padding: 1px 4px;
background: #e3f2fd;
border: 1px solid #2196f3;
border-radius: 3px;
color: #1976d2;
font-size: 10px;
font-weight: bold;
flex-shrink: 0;
}
.song-artist {
font-size: 14px;
color: #666;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.requester-info,
.votes-info {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
white-space: nowrap;
}
.label {
font-weight: 500;
margin-right: 4px;
}
.value {
color: #333;
}
/* 打印样式 */
@media print {
.schedule-item-print {
color: #000 !important;
background: #fff !important;
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
}
.item-content {
display: flex !important;
flex-direction: row !important;
align-items: center !important;
width: 100% !important;
gap: 12px !important;
}
.sequence-number {
background: #f0f0f0 !important;
color: #000 !important;
width: 30px !important;
height: 30px !important;
flex-shrink: 0 !important;
}
.cover-section {
width: 40px !important;
height: 40px !important;
flex-shrink: 0 !important;
}
.song-info {
flex: 1 !important;
min-width: 0 !important;
}
.song-title {
color: #000 !important;
font-size: 16px !important;
white-space: nowrap !important;
overflow: hidden !important;
text-overflow: ellipsis !important;
}
.song-artist,
.label,
.value {
color: #333 !important;
}
.time-range {
color: #666 !important;
}
.requester-info,
.votes-info,
.playtime-info {
display: flex !important;
align-items: center !important;
font-size: 12px !important;
white-space: nowrap !important;
flex-shrink: 0 !important;
}
}
/* 紧凑模式 */
.compact .schedule-item-print {
padding: 4px 0;
}
.compact .item-content {
gap: 8px;
}
.compact .sequence-number {
width: 24px;
height: 24px;
font-size: 12px;
}
.compact .cover-section {
width: 32px;
height: 32px;
}
.compact .song-title {
font-size: 14px;
}
.compact .song-artist {
font-size: 12px;
}
.compact .requester-info,
.compact .votes-info,
.compact .playtime-info {
font-size: 11px;
}
</style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+638
View File
@@ -0,0 +1,638 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-10 pb-20 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">学期管理</h2>
<p class="text-xs text-zinc-500 mt-1">
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
</p>
</div>
<button
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
@click="openAddModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
添加新学期
</button>
</div>
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
<!-- 左侧栏:当前活跃学期 -->
<div class="lg:col-span-5 space-y-6">
<div class="flex items-center gap-2 px-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-amber-500 fill-amber-500"
>
<polygon
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
/>
</svg>
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
当前活跃学期
</h3>
</div>
<div
v-if="currentSemester"
class="relative bg-gradient-to-br from-blue-600 to-blue-700 rounded-3xl p-10 shadow-2xl shadow-blue-900/20 overflow-hidden group"
>
<div class="relative z-10 space-y-6">
<div
class="w-16 h-16 rounded-3xl bg-white/10 backdrop-blur-md flex items-center justify-center text-white border border-white/20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
<div class="space-y-2">
<h4 class="text-3xl font-black text-white tracking-tight leading-tight">
{{ currentSemester.name }}
</h4>
<div class="flex items-center gap-2 text-blue-100/60 font-bold text-xs">
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
创建于 {{ formatDate(currentSemester.createdAt) }}
</div>
</div>
<div class="pt-4">
<span
class="px-4 py-2 bg-white text-blue-600 text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
>
正在进行中
</span>
</div>
</div>
<!-- 装饰性背景元素 -->
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-white/5 blur-3xl rounded-full" />
<div class="absolute top-10 right-10 opacity-10">
<svg
xmlns="http://www.w3.org/2000/svg"
width="160"
height="160"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-white"
>
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
</div>
</div>
<div
v-else
class="bg-zinc-900/30 border border-zinc-800 border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-zinc-700"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-sm font-bold text-zinc-600">未设置当前活跃学期</p>
<button class="text-xs text-blue-500 font-black hover:underline" @click="openAddModal">
立即创建
</button>
</div>
<div class="p-6 bg-zinc-900/20 border border-zinc-800 rounded-3xl flex items-start gap-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-zinc-500 shrink-0 mt-0.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
<div class="space-y-1">
<p class="text-xs font-bold text-zinc-400">学期切换说明</p>
<p class="text-[11px] text-zinc-600 leading-relaxed">
切换学期后,前端点歌界面将清空旧排期。所有历史数据会自动归档至对应学期下,您仍可在数据中心进行查看。
</p>
</div>
</div>
</div>
<!-- 右侧栏:学期历史记录 -->
<div class="lg:col-span-7 space-y-6">
<div class="flex items-center justify-between px-1">
<div class="flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-zinc-600"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M12 7v5l4 2" />
</svg>
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
学期历史记录
</h3>
</div>
<span class="text-[10px] font-black text-zinc-700">共 {{ semesters.length }} 条</span>
</div>
<div class="space-y-4">
<div
v-if="loading && semesters.length === 0"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-zinc-600">加载中...</p>
</div>
<div
v-for="sem in sortedSemesters"
:key="sem.id"
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
:class="
sem.isActive
? 'bg-blue-600/5 border-blue-500/30'
: 'bg-zinc-900/40 border-zinc-800 hover:border-zinc-700'
"
>
<div class="flex items-center gap-5">
<div
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
:class="
sem.isActive
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="21 8 21 21 3 21 3 8" />
<rect x="1" y="3" width="22" height="5" />
<line x1="10" y1="12" x2="14" y2="12" />
</svg>
</div>
<div>
<h5
class="font-bold transition-colors"
:class="
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
"
>
{{ sem.name }}
</h5>
<p class="text-[10px] text-zinc-600 font-medium uppercase tracking-widest mt-0.5">
创建于 {{ formatDate(sem.createdAt) }}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<div
v-if="sem.isActive"
class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-500 rounded-xl border border-emerald-500/20"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
<polyline points="22 4 12 14.01 9 11.01" />
</svg>
<span class="text-[10px] font-black uppercase tracking-widest">当前学期</span>
</div>
<template v-else>
<button
:disabled="loading"
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white text-[10px] font-black rounded-xl border border-zinc-700 transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="setActive(sem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
设为当前
</button>
<button
:disabled="loading"
class="p-2.5 text-zinc-700 hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="deleteSemester(sem.id)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
</button>
</template>
</div>
</div>
<div
v-if="semesters.length === 0 && !loading"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
</div>
</div>
</div>
</div>
<!-- 添加学期弹窗 -->
<div
v-if="showAddModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
>
<div
class="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
@click.stop
>
<div class="p-8 space-y-6">
<div class="flex items-center justify-between">
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
<button
class="p-2 text-zinc-500 hover:text-white transition-colors"
@click="closeAddModal"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<div class="space-y-6">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>学期名称</label
>
<input
v-model="newSemester.name"
type="text"
placeholder="例如: 2025学年第一学期"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 font-bold transition-all"
>
</div>
<label class="flex items-center gap-3 cursor-pointer group px-1">
<input
v-model="newSemester.isActive"
type="checkbox"
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600 transition-all"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
>设为当前活跃学期</span
>
<p class="text-[10px] text-zinc-600 font-medium">
创建后将立即激活此学期,原学期将变为历史状态
</p>
</div>
</label>
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="text-amber-500 shrink-0 mt-0.5"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p class="text-[10px] text-zinc-500 leading-normal">
提示:请按照统一的命名规范设置学期名称,这有助于数据归档后的检索与分析。
</p>
</div>
</div>
</div>
<div class="p-6 bg-zinc-800/50 border-t border-zinc-800 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors"
@click="closeAddModal"
>
取消
</button>
<button
:disabled="submitting || !newSemester.name.trim()"
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:opacity-50 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
@click="handleAddSemester"
>
{{ submitting ? '创建中...' : '创建学期' }}
</button>
</div>
</div>
</div>
<!-- 确认删除对话框 -->
<ConfirmDialog
:loading="loading"
:show="showDeleteDialog"
cancel-text="取消"
confirm-text="确认删除"
:message="deleteMessage"
title="确认删除学期"
type="danger"
@close="showDeleteDialog = false"
@confirm="confirmDelete"
/>
</div>
</template>
<script setup>
import { onMounted, ref, computed } from 'vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useToast } from '~/composables/useToast'
const { showToast: showNotification } = useToast()
const {
semesters,
currentSemester,
loading,
error,
fetchSemesters,
fetchCurrentSemester,
createSemester,
setActiveSemester,
deleteSemester: deleteSemesterAPI
} = useSemesters()
const showAddModal = ref(false)
const showDeleteDialog = ref(false)
const deleteTargetId = ref(null)
const deleteTargetName = ref('')
const submitting = ref(false)
const newSemester = ref({
name: '',
isActive: false
})
// 按创建时间排序
const sortedSemesters = computed(() => {
return [...semesters.value].sort((a, b) => {
return new Date(b.createdAt) - new Date(a.createdAt)
})
})
// 删除确认信息
const deleteMessage = computed(() => {
return `确定要彻底删除 "${deleteTargetName.value}" 吗?此操作将同步清除该学期下的所有点歌记录、排期数据和统计信息。数据一旦删除将无法找回。`
})
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
})
}
// 设置活跃学期
const setActive = async (semesterId) => {
try {
const success = await setActiveSemester(semesterId)
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
// 或者直接依据success返回值判断
if (success) {
showNotification('活跃学期设置成功!', 'success')
} else {
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
showNotification(error.value || '设置活跃学期失败,请稍后重试', 'error')
}
} catch (err) {
showNotification(err.message || '设置失败', 'error')
}
}
// 准备删除学期
const deleteSemester = (semesterId) => {
const semester = semesters.value.find((s) => s.id === semesterId)
if (semester) {
deleteTargetId.value = semesterId
deleteTargetName.value = semester.name
showDeleteDialog.value = true
}
}
// 确认删除
const confirmDelete = async () => {
if (!deleteTargetId.value) return
try {
const success = await deleteSemesterAPI(deleteTargetId.value)
if (success) {
showNotification('学期删除成功!', 'success')
} else {
showNotification(error.value || '删除学期失败', 'error')
}
} catch (err) {
showNotification(err.message || '删除失败', 'error')
}
showDeleteDialog.value = false
deleteTargetId.value = null
deleteTargetName.value = ''
}
// 处理添加学期
const handleAddSemester = async () => {
if (!newSemester.value.name.trim()) return
submitting.value = true
try {
const result = await createSemester({
name: newSemester.value.name,
isActive: newSemester.value.isActive
})
if (result) {
showNotification('学期创建成功!', 'success')
closeAddModal()
} else {
showNotification(error.value || '创建学期失败', 'error')
}
} catch (err) {
console.error('创建学期失败:', err)
showNotification(err.message || '创建学期失败', 'error')
} finally {
submitting.value = false
}
}
// 关闭添加弹窗
const closeAddModal = () => {
showAddModal.value = false
newSemester.value = {
name: '',
isActive: false
}
}
const getRecommendedName = () => {
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
let academicYear = year
let term = '第一学期'
if (month >= 3 && month <= 8) {
academicYear = year - 1
term = '第二学期'
} else if (month <= 2) {
academicYear = year - 1
term = '第一学期'
} else {
academicYear = year
term = '第一学期'
}
return `${academicYear}学年${term}`
}
const openAddModal = () => {
newSemester.value.name = getRecommendedName()
showAddModal.value = true
}
// 组件挂载时获取数据
onMounted(async () => {
await Promise.all([fetchCurrentSemester(), fetchSemesters()])
})
</script>
<style scoped></style>
+288
View File
@@ -0,0 +1,288 @@
<!-- 侧边栏组件 -->
<template>
<aside
:class="[
'fixed inset-y-0 left-0 z-50 w-64 bg-[#09090b] border-r border-zinc-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0',
isOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex flex-col h-full p-4">
<!-- 品牌标识区域 -->
<div class="flex items-center px-2 mb-6 mt-2">
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<!-- Logo 图标 -->
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
<img
:src="logo"
alt="VoiceHub Logo"
class="w-8 h-8 object-contain"
width="32"
height="32"
decoding="async"
loading="lazy"
>
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
<p
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
>
管理控制台
</p>
</div>
</NuxtLink>
</div>
<!-- 导航菜单区域 -->
<nav class="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-zinc-600 uppercase tracking-[0.2em] mb-2">
{{ group.section }}
</h3>
<!-- 菜单项列表 -->
<template v-for="item in group.items" :key="item.id">
<button
v-if="permissions.canAccessPage(item.permissionId || item.id)"
:class="[
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/80 focus-visible:ring-inset',
activeTab === item.id
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 border-transparent',
getTourClass(item.id)
]"
@click="onNavigate(item.id)"
>
<!-- 菜单图标 -->
<component
:is="item.icon"
:size="18"
:class="
activeTab === item.id
? 'text-blue-400'
: 'text-zinc-500 group-hover:text-zinc-300'
"
/>
<span class="truncate">{{ item.label }}</span>
<!-- 选中状态指示器 -->
<div
v-if="activeTab === item.id"
class="ml-auto w-1 h-1 bg-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
/>
</button>
</template>
</template>
</div>
</nav>
<!-- 用户信息及退出登录 -->
<div class="mt-4 pt-4 border-t border-zinc-800">
<div
class="flex items-center gap-3 p-3 rounded-lg bg-zinc-900/50 border border-zinc-800/50 hover:bg-zinc-800/30 transition-colors"
>
<!-- 用户头像/首字母 -->
<img
v-if="currentUser?.avatar && !avatarError"
:src="currentUser.avatar"
class="w-10 h-10 rounded-lg object-cover border border-zinc-700 shrink-0"
@error="avatarError = true"
>
<div
v-else
class="w-10 h-10 rounded-lg bg-zinc-800 flex items-center justify-center text-zinc-400 font-bold border border-zinc-700 shrink-0"
>
{{ (currentUser?.name || '管').charAt(0) }}
</div>
<!-- 用户详细信息 -->
<div class="flex-1 min-w-0">
<p class="text-xs font-black truncate text-zinc-100">
{{ currentUser?.name || '管理员' }}
</p>
<p
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
>
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
</p>
</div>
<!-- 退出按钮 -->
<button
class="p-2 text-zinc-600 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
title="退出登录"
@click="$emit('logout')"
>
<LogOut :size="16" />
</button>
</div>
</div>
</div>
</aside>
</template>
<script setup>
/**
* 后台管理侧边栏组件
*/
import {
LayoutDashboard,
CalendarDays,
Printer,
Music2,
BarChart3,
Users,
Key,
Bell,
Mail,
LogOut,
Clock,
FileEdit,
ListChecks,
BookOpen,
Ban,
Globe,
Image as ImageIcon,
ScrollText,
Database,
Lock,
Shield
} from '@lucide/vue'
import logo from '~~/public/images/logo.png'
import { rolesConfig } from '~/composables/usePermissions'
const avatarError = ref(false)
const props = defineProps({
// 侧边栏是否打开(移动端)
isOpen: Boolean,
// 当前激活的标签页 ID
activeTab: String,
// 当前登录用户信息
currentUser: Object,
// 权限控制对象
permissions: Object,
// 站点标题
siteTitle: String
})
watch(
() => props.currentUser?.avatar,
() => {
avatarError.value = false
}
)
const emit = defineEmits(['navigate', 'close', 'logout'])
// 菜单分组配置
const menuGroups = [
{
section: '数据中心',
items: [
{ icon: LayoutDashboard, label: '数据概览', id: 'overview' },
{ icon: BookOpen, label: '流程与教程', id: 'workflow-tutorial' },
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
]
},
{
section: '播音管理',
items: [
{ icon: Music2, label: '歌曲管理', id: 'songs' },
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
{ icon: Printer, label: '打印排期', id: 'print' },
{ icon: ImageIcon, label: '图片歌单', id: 'playlist-card' }
]
},
{
section: '业务设置',
items: [
{ 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: '用户与权限',
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' }]
}
]
/**
* 判断是否应该显示该菜单组
* @param {Object} group 菜单组对象
*/
const shouldShowGroup = (group) => {
if (!props.permissions) return true
return group.items.some((item) => props.permissions.canAccessPage(item.permissionId || item.id))
}
/**
* 导航点击处理
* @param {string} id 菜单项 ID
*/
const onNavigate = (id) => {
if (id === 'password') {
navigateTo('/change-password')
return
}
emit('navigate', id)
}
const getTourClass = (id) => `tour-nav-${id}`
/**
* 获取角色显示名称
* @param {string} role 角色标识
*/
const getRoleDisplayName = (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>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
+548
View File
@@ -0,0 +1,548 @@
<template>
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
<!-- 顶部标题栏 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">站点配置</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
</p>
</div>
<div class="flex gap-3">
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetForm"
>
<RotateCcw :size="14" /> 重置
</button>
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
@click="saveConfig"
>
<template v-if="saving">
<div
class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"
/>
保存中...
</template>
<template v-else-if="saveSuccess"> <CheckCircle2 :size="14" /> 已保存 </template>
<template v-else> <Save :size="14" /> 保存配置 </template>
</button>
</div>
</div>
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
<div
class="w-8 h-8 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<p class="text-zinc-500 text-sm">加载配置中...</p>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 基础信息 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Globe :size="16" class="text-blue-500" /> 基础信息
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">站点标题</label>
<input
v-model="formData.siteTitle"
type="text"
placeholder="请输入站点标题"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">备案号 (ICP)</label>
<input
v-model="formData.icpNumber"
type="text"
placeholder="请输入备案号"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">公安联网备案号</label>
<input
v-model="formData.gonganNumber"
type="text"
placeholder="请输入公安备案号 (如:陕公网安备61011302001964号)"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">站点描述</label>
<textarea
v-model="formData.siteDescription"
:rows="3"
placeholder="请输入站点描述"
:class="[inputClass, 'resize-none']"
/>
</div>
<div>
<label :class="labelClass">站点版本号</label>
<input
v-model="formData.siteVersion"
type="text"
placeholder="如:v2.4.1 或 2026.04"
:class="inputClass"
>
</div>
</div>
</section>
<!-- 视觉识别 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
</h3>
<div class="space-y-4">
<div>
<label :class="labelClass">站点 Logo URL</label>
<input
v-model="formData.siteLogoUrl"
type="text"
placeholder="请输入Logo图片URL"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">首页学校 Logo URL (大尺寸)</label>
<input
v-model="formData.schoolLogoHomeUrl"
type="text"
placeholder="请输入首页学校Logo URL"
:class="inputClass"
>
</div>
<div>
<label :class="labelClass">打印排期 Logo URL (小尺寸)</label>
<input
v-model="formData.schoolLogoPrintUrl"
type="text"
placeholder="请输入打印页学校Logo URL"
:class="inputClass"
>
</div>
</div>
</section>
<!-- 投稿须知 -->
<section
class="lg:col-span-2 bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 space-y-6"
>
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<FileText :size="16" class="text-emerald-500" /> 投稿须知
</h3>
<textarea
v-model="formData.submissionGuidelines"
:rows="6"
placeholder="请输入投稿须知内容"
:class="[inputClass, 'font-mono text-xs leading-relaxed min-h-[150px]']"
/>
</section>
<!-- 投稿逻辑设置 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Settings2 :size="16" class="text-amber-500" /> 投稿逻辑设置
</h3>
<div class="space-y-6">
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用联合投稿</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户添加联合投稿人并发起协作投稿</p>
</div>
<input
v-model="formData.enableCollaborativeSubmission"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿备注留言</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户在投稿时附加公开或仅管理员可见的备注</p>
</div>
<input
v-model="formData.enableSubmissionRemarks"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用重播申请</p>
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户对本学期已播放过的歌曲再次申请</p>
</div>
<input
v-model="formData.enableReplayRequests"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div class="space-y-4">
<div
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿限额</p>
<p class="text-[10px] text-zinc-500 mt-0.5">限制单个用户的点歌频率</p>
</div>
<input
v-model="formData.enableSubmissionLimit"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<div v-if="formData.enableSubmissionLimit" class="space-y-4">
<div class="grid grid-cols-3 gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-xl">
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'daily'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('daily')"
>
每日限额
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'weekly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('weekly')"
>
每周限额
</button>
<button
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'monthly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('monthly')"
>
每月限额
</button>
</div>
<div>
<label :class="labelClass"
>{{ currentLimitType === 'daily' ? '单日' : (currentLimitType === 'weekly' ? '单周' : '单月') }}投稿上限</label
>
<div class="relative">
<input
v-model.number="currentLimitValue"
type="number"
min="0"
:class="inputClass"
>
<span
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-zinc-700 uppercase"
>首 / 人</span
>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- 安全与隐私设置 -->
<section :class="cardClass">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
>
<Shield :size="16" class="text-rose-500" /> 安全与隐私设置
</h3>
<div class="space-y-4">
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 pt-0.5">
<input
id="show-keywords"
v-model="formData.showBlacklistKeywords"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<label for="show-keywords" class="cursor-pointer">
<p class="text-xs font-bold text-zinc-200">显示黑名单具体关键词</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
</p>
</label>
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="flex items-start gap-4">
<div class="shrink-0 pt-0.5">
<input
id="hide-students"
v-model="formData.hideStudentInfo"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
>
</div>
<label for="hide-students" class="cursor-pointer">
<p class="text-xs font-bold text-zinc-200">隐藏学生详细信息</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
</p>
</label>
</div>
</div>
<div
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle class="text-blue-500 shrink-0 mt-0.5" :size="14" />
<p class="text-[10px] text-zinc-500 leading-normal">
站点基础配置在保存后将立即对所有终端生效。请在修改关键业务逻辑(如投稿限额)前确保已知晓对现有用户的影响。
</p>
</div>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import {
Globe,
ImageIcon,
FileText,
Settings2,
Shield,
Save,
RotateCcw,
CheckCircle2,
AlertCircle
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
const { showToast: showNotification } = useToast()
const loading = ref(true)
const saving = ref(false)
const saveSuccess = ref(false)
// 样式类常量
const inputClass =
'w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800'
const labelClass = 'text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2'
const cardClass = 'bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 shadow-xl space-y-6'
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
3. 禁止投递含有违规内容的歌曲
4. 点播的歌曲将由管理员进行审核
5. 审核通过后将安排在播放时段播出
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
8. 最终解释权归广播站所有`
const formData = ref({
siteTitle: '',
siteLogoUrl: '',
schoolLogoHomeUrl: '',
schoolLogoPrintUrl: '',
siteDescription: '',
siteVersion: '',
submissionGuidelines: '',
icpNumber: '',
gonganNumber: '',
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
enableReplayRequests: false,
enableSubmissionLimit: false,
dailySubmissionLimit: 5,
weeklySubmissionLimit: null,
monthlySubmissionLimit: null,
showBlacklistKeywords: false,
hideStudentInfo: true
})
const originalData = ref({})
// 当前限额类型和值的快捷访问
const currentLimitType = computed(() => {
if (formData.value.monthlySubmissionLimit !== null) return 'monthly'
return formData.value.weeklySubmissionLimit !== null ? 'weekly' : 'daily'
})
const currentLimitValue = computed({
get: () => {
if (currentLimitType.value === 'monthly') return formData.value.monthlySubmissionLimit
return currentLimitType.value === 'daily'
? formData.value.dailySubmissionLimit
: formData.value.weeklySubmissionLimit
},
set: (val) => {
if (currentLimitType.value === 'monthly') {
formData.value.monthlySubmissionLimit = val
} else if (currentLimitType.value === 'daily') {
formData.value.dailySubmissionLimit = val
} else {
formData.value.weeklySubmissionLimit = val
}
}
})
// 加载配置
const loadConfig = async () => {
try {
loading.value = true
const response = await fetch('/api/admin/system-settings', {
credentials: 'include'
})
if (!response.ok) throw new Error('获取配置失败')
const data = await response.json()
formData.value = {
siteTitle: data.siteTitle || '',
siteLogoUrl: data.siteLogoUrl || '',
schoolLogoHomeUrl: data.schoolLogoHomeUrl || '',
schoolLogoPrintUrl: data.schoolLogoPrintUrl || '',
siteDescription: data.siteDescription || '',
siteVersion: data.siteVersion || '',
submissionGuidelines: data.submissionGuidelines || defaultSubmissionGuidelines,
icpNumber: data.icpNumber || '',
gonganNumber: data.gonganNumber || '',
enableCollaborativeSubmission: data.enableCollaborativeSubmission !== false,
enableSubmissionRemarks: !!data.enableSubmissionRemarks,
enableReplayRequests: !!data.enableReplayRequests,
enableSubmissionLimit: !!data.enableSubmissionLimit,
dailySubmissionLimit: data.dailySubmissionLimit ?? 5,
weeklySubmissionLimit: data.weeklySubmissionLimit ?? null,
monthlySubmissionLimit: data.monthlySubmissionLimit ?? null,
showBlacklistKeywords: !!data.showBlacklistKeywords,
hideStudentInfo: data.hideStudentInfo ?? true
}
originalData.value = JSON.parse(JSON.stringify(formData.value))
} catch (error) {
console.error('加载配置失败:', error)
showNotification('加载配置失败', 'error')
} finally {
loading.value = false
}
}
// 保存配置
const saveConfig = async () => {
try {
saving.value = true
const configToSave = {
...formData.value,
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
siteVersion: (formData.value.siteVersion || '').trim() || null,
submissionGuidelines:
(formData.value.submissionGuidelines || '').trim() || defaultSubmissionGuidelines,
// 确保根据限额类型处理空值
dailySubmissionLimit:
currentLimitType.value === 'daily' ? formData.value.dailySubmissionLimit : null,
weeklySubmissionLimit:
currentLimitType.value === 'weekly' ? formData.value.weeklySubmissionLimit : null,
monthlySubmissionLimit:
currentLimitType.value === 'monthly' ? formData.value.monthlySubmissionLimit : null
}
const response = await fetch('/api/admin/system-settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(configToSave)
})
if (!response.ok) throw new Error('保存配置失败')
saveSuccess.value = true
originalData.value = JSON.parse(JSON.stringify(formData.value))
showNotification('配置保存成功!', 'success')
setTimeout(() => {
saveSuccess.value = false
}, 3000)
} catch (error) {
console.error('保存配置失败:', error)
showNotification('保存配置失败,请重试', 'error')
} finally {
saving.value = false
}
}
// 处理限额类型变化
const handleLimitTypeChange = (type) => {
const limits = {
daily: { key: 'dailySubmissionLimit', default: 5 },
weekly: { key: 'weeklySubmissionLimit', default: 20 },
monthly: { key: 'monthlySubmissionLimit', default: 50 }
}
// 将非当前类型的限额设为 null
Object.keys(limits).forEach((limitType) => {
if (limitType !== type) {
formData.value[limits[limitType].key] = null
}
})
// 如果当前类型的限额为 null,则设置默认值
const targetLimit = limits[type]
if (formData.value[targetLimit.key] === null) {
formData.value[targetLimit.key] = targetLimit.default
}
}
// 重置表单
const resetForm = () => {
formData.value = JSON.parse(JSON.stringify(originalData.value))
}
onMounted(loadConfig)
</script>
<style scoped>
input::-webkit-outer-spin-button,
input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
</style>
+348
View File
@@ -0,0 +1,348 @@
<template>
<div class="max-w-[1400px] mx-auto space-y-10 pb-20 px-2">
<!-- 头部区域 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">邮件服务配置</h2>
<p class="text-xs text-zinc-500 mt-1">
配置 SMTP 服务以发送系统验证码、投稿通知及其他重要提醒
</p>
</div>
<div class="flex items-center gap-3">
<button
class="flex items-center gap-2 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
:disabled="reloading"
@click="reloadSmtpConfig"
>
<RotateCw :size="14" :class="reloading ? 'animate-spin' : ''" />
{{ reloading ? '重载中...' : '重载SMTP' }}
</button>
<button
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
:disabled="saving"
@click="saveConfig"
>
<Save :size="14" /> {{ saving ? '保存中...' : '保存配置' }}
</button>
</div>
</div>
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
<!-- 左侧栏:SMTP 设置与测试 -->
<div class="xl:col-span-4 space-y-8">
<!-- SMTP 核心设置 -->
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
<div class="flex items-center justify-between">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<Server :size="16" class="text-blue-500" /> 服务配置
</h3>
<button
class="relative w-10 h-5 rounded-full transition-colors"
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-800'"
@click="config.smtpEnabled = !config.smtpEnabled"
>
<div
class="absolute top-1 w-3 h-3 bg-white rounded-full transition-all"
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
/>
</button>
</div>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>SMTP 服务器</label
>
<input
v-model="config.smtpHost"
type="text"
placeholder="smtp.example.com"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>端口</label
>
<input
v-model.number="config.smtpPort"
type="number"
placeholder="587"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>加密方式</label
>
<CustomSelect
:model-value="config.smtpSecure ? 'SSL/TLS' : '无'"
:options="['SSL/TLS', '无']"
class="w-full"
@update:model-value="(val) => (config.smtpSecure = val === 'SSL/TLS')"
/>
</div>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人账号</label
>
<input
v-model="config.smtpUsername"
type="text"
placeholder="your-email@example.com"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人邮箱 (From Email)</label
>
<input
v-model="config.smtpFromEmail"
type="text"
:placeholder="config.smtpUsername || 'your-email@example.com'"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
<p class="text-[9px] text-zinc-500 px-1 italic">
通常与发件人账号一致,部分服务商强制要求一致。
</p>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>授权码 / 密码</label
>
<input
v-model="config.smtpPassword"
type="password"
placeholder="••••••••••••"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>发件人姓名</label
>
<input
v-model="config.smtpFromName"
type="text"
placeholder="校园广播站"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
</div>
</div>
</section>
<!-- SMTP 测试模块 -->
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
>
<Send :size="16" class="text-emerald-500" /> 服务测试
</h3>
<div class="space-y-4">
<div class="space-y-1.5">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>测试接收邮箱</label
>
<div class="flex gap-2">
<input
v-model="testEmail"
type="email"
placeholder="输入测试邮箱地址"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="testing || !testEmail || !config.smtpEnabled"
@click="sendTestEmail"
>
<Check v-if="testResult?.success" :size="14" class="text-emerald-500" />
<Send v-else :size="14" />
{{ testing ? '发送中' : testResult?.success ? '已发送' : '发送' }}
</button>
</div>
</div>
<div v-if="testResult">
<div
class="flex items-center gap-2 p-3 rounded-xl text-xs"
:class="
testResult.success
? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
: 'bg-red-500/10 text-red-500 border border-red-500/20'
"
>
<CheckCircle v-if="testResult.success" :size="14" />
<XCircle v-else :size="14" />
<div class="flex flex-col gap-0.5">
<p class="font-bold">{{ testResult.message }}</p>
<p v-if="testResult.detail" class="text-[10px] opacity-80 break-all font-mono">
{{ testResult.detail }}
</p>
</div>
</div>
</div>
</div>
</section>
</div>
<!-- 右侧栏:模板编辑器 -->
<div class="xl:col-span-8">
<EmailTemplateManager />
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
import { useToast } from '~/composables/useToast'
import EmailTemplateManager from '~/components/Admin/EmailTemplateManager.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { Server, Save, Check, Send, CheckCircle, XCircle, RotateCw } from '@lucide/vue'
const { showToast: showNotification } = useToast()
// 响应式数据
const config = ref({
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUsername: '',
smtpPassword: '',
smtpFromEmail: '',
smtpFromName: '校园广播站'
})
const testEmail = ref('')
const testing = ref(false)
const saving = ref(false)
const reloading = ref(false)
const testResult = ref(null)
const originalConfig = ref({})
// 加载配置
const loadConfig = async () => {
try {
const response = await $fetch('/api/admin/system-settings')
config.value = {
smtpEnabled: response.smtpEnabled || false,
smtpHost: response.smtpHost || '',
smtpPort: response.smtpPort || 587,
smtpSecure: response.smtpSecure || false,
smtpUsername: response.smtpUsername || '',
smtpPassword: response.smtpPassword || '',
smtpFromEmail: response.smtpFromEmail || '',
smtpFromName: response.smtpFromName || '校园广播站'
}
// 保存原始配置用于重置
originalConfig.value = { ...config.value }
} catch (error) {
console.error('加载SMTP配置失败:', error)
showNotification('加载配置失败', 'error')
}
}
// 保存配置
const saveConfig = async () => {
if (config.value.smtpEnabled) {
// 验证必填字段
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
showNotification('请填写完整的SMTP配置信息', 'error')
return
}
}
saving.value = true
try {
await $fetch('/api/admin/system-settings', {
method: 'POST',
body: config.value
})
originalConfig.value = { ...config.value }
showNotification('SMTP配置保存成功', 'success')
} catch (error) {
console.error('保存SMTP配置失败:', error)
showNotification(error.data?.message || '保存配置失败', 'error')
} finally {
saving.value = false
}
}
// 重置配置
const reloadSmtpConfig = async () => {
reloading.value = true
try {
const response = await $fetch('/api/admin/smtp/reload', {
method: 'POST'
})
if (!response.success) {
throw new Error(response.message || 'SMTP重载失败')
}
showNotification(response.message || 'SMTP配置已重载', 'success')
} catch (error) {
console.error('重载SMTP配置失败:', error)
showNotification(error.data?.message || error.message || '重载SMTP配置失败', 'error')
} finally {
reloading.value = false
}
}
// 测试连接
// 发送测试邮件
const sendTestEmail = async () => {
if (!testEmail.value) {
showNotification('请输入测试邮箱地址', 'error')
return
}
testing.value = true
testResult.value = null
try {
const response = await $fetch('/api/admin/smtp/test-email', {
method: 'POST',
body: {
...config.value,
testEmail: testEmail.value
}
})
testResult.value = response
if (response.success) {
showNotification('测试邮件发送成功', 'success')
setTimeout(() => {
testResult.value = null
}, 5000)
}
} catch (error) {
console.error('发送测试邮件失败:', error)
testResult.value = {
success: false,
message: error.data?.message || '发送测试邮件失败'
}
} finally {
testing.value = false
}
}
// 生命周期
onMounted(() => {
loadConfig()
})
</script>
<style scoped></style>
+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
@@ -0,0 +1,77 @@
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
@click="close"
>
<div
class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">投稿备注留言</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="close">
<X :size="20" />
</button>
</div>
<div class="p-8 space-y-4">
<div class="flex items-center gap-3">
<p class="text-xs text-zinc-500 font-medium">{{ songTitle }}</p>
<span
:class="[
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border',
isPublic
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
]"
>
{{ isPublic ? '公开备注' : '仅管理员可见' }}
</span>
</div>
<div class="bg-zinc-950/50 border border-zinc-800/50 rounded-xl p-4">
<p class="text-sm text-zinc-200 leading-relaxed whitespace-pre-wrap">
{{ content }}
</p>
</div>
</div>
</div>
</div>
</Transition>
</template>
<script setup>
import { X } from '@lucide/vue'
defineProps({
show: {
type: Boolean,
default: false
},
songTitle: {
type: String,
default: ''
},
content: {
type: String,
default: ''
},
isPublic: {
type: Boolean,
default: true
}
})
const emit = defineEmits(['close'])
const close = () => {
emit('close')
}
</script>
File diff suppressed because it is too large Load Diff
+483
View File
@@ -0,0 +1,483 @@
<template>
<transition
enter-active-class="transition ease-out duration-200"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition ease-in duration-150"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click="handleOverlayClick"
>
<!-- 遮罩层 -->
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<!-- 模态框面板 -->
<transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 scale-95 translate-y-4"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-4"
>
<div
v-if="show"
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div
class="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50 backdrop-blur-sm"
>
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
<button
class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
@click="$emit('close')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 6 6 18" />
<path d="m6 6 18 12" />
</svg>
</button>
</div>
<!-- 主体内容 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-zinc-900">
<!-- 加载状态 -->
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
<LoadingState message="加载数据中..." spinner-type="circle" />
</div>
<!-- 错误状态 -->
<div
v-else-if="error"
class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-4"
>
<Icon class="text-4xl text-yellow-500" name="alert-triangle" size="48" />
<p>{{ error }}</p>
<button
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm font-medium"
@click="retryFetch"
>
重试
</button>
</div>
<!-- 内容 -->
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
<!-- 用户信息头部 -->
<div class="px-6 py-4 bg-zinc-900 border-b border-zinc-800">
<div class="flex flex-col gap-1">
<div class="flex items-center gap-3">
<h4 class="text-base font-medium text-white">{{ userSongs.user.name }}</h4>
<span class="text-sm text-zinc-500 font-mono">{{
userSongs.user.username
}}</span>
</div>
<p
v-if="userSongs.user.grade || userSongs.user.class"
class="text-sm text-zinc-400"
>
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
</p>
</div>
</div>
<!-- 工具栏 (标签页 + 过滤) -->
<div
class="flex flex-col sm:flex-row items-center justify-between border-b border-zinc-800 bg-zinc-900/50"
>
<!-- 标签页 -->
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
<button
v-for="tab in tabs"
:key="tab.id"
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
:class="
activeTab === tab.id
? 'text-blue-500'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
"
@click="activeTab = tab.id"
>
{{ tab.label }}
<span
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
:class="
activeTab === tab.id
? 'bg-blue-500/10 text-blue-500'
: 'bg-zinc-800 text-zinc-500'
"
>
{{ getTabCount(tab.id) }}
</span>
<!-- 激活指示器 -->
<div
v-if="activeTab === tab.id"
class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500"
/>
</button>
</div>
<!-- 学期过滤器 -->
<div
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-zinc-800 flex items-center gap-3 bg-zinc-900/30"
>
<CustomSelect
v-model="selectedSemester"
:options="[{ label: '全部学期', value: null }, ...semesterOptions]"
label="学期过滤"
placeholder="全部学期"
class-name="w-full sm:w-48"
/>
</div>
</div>
<!-- 歌曲列表 -->
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3 custom-scrollbar">
<div
v-if="filteredSongs.length === 0"
class="flex flex-col items-center justify-center py-20 text-zinc-500"
>
<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-zinc-800 bg-zinc-800/20 hover:bg-zinc-800/50 hover:border-zinc-700 transition-all duration-200"
>
<!-- 歌曲信息 -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<h5 class="text-sm font-medium text-white truncate">{{ song.title }}</h5>
<span
v-if="song.semester"
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-800 text-zinc-400 border border-zinc-700"
>
{{ song.semester }}
</span>
</div>
<div class="text-sm text-zinc-400 truncate">{{ song.artist }}</div>
<!-- Meta Info (Mobile) -->
<div
class="flex flex-wrap items-center gap-3 mt-3 sm:hidden text-xs text-zinc-500"
>
<span>{{ getMetaTime(song) }}</span>
<span v-if="song.voteCount !== undefined">{{ song.voteCount }} 票</span>
<span v-if="song.requestCount !== undefined"
>{{ song.requestCount }} 人申请</span
>
</div>
</div>
<!-- 状态与元数据 (桌面端) -->
<div class="flex items-center gap-4 sm:gap-6">
<!-- 状态标签 -->
<div class="flex-shrink-0">
<span
class="px-2.5 py-1 rounded-full text-xs font-medium border"
:class="getStatusClasses(song)"
>
{{ getStatusText(song) }}
</span>
</div>
<!-- Meta Info (Desktop) -->
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
<span class="text-xs text-zinc-400">{{ getMetaTime(song) }}</span>
<div class="flex items-center gap-2">
<span v-if="song.voteCount !== undefined" class="text-xs text-zinc-500"
>{{ song.voteCount }} 票</span
>
<span v-if="song.requestCount !== undefined" class="text-xs text-zinc-500"
>{{ song.requestCount }} 人申请</span
>
</div>
</div>
<!-- Submitter Info (if available) -->
<div v-if="song.requester" class="hidden sm:block text-right min-w-[80px]">
<div class="text-xs text-zinc-300">{{ song.requester.name }}</div>
<div class="text-[10px] text-zinc-500">
{{ song.requester.grade }}{{ song.requester.class }}
</div>
</div>
</div>
<!-- 投稿人信息 (移动端) -->
<div
v-if="song.requester"
class="sm:hidden pt-3 mt-1 border-t border-zinc-800/50 flex justify-between items-center text-xs"
>
<span class="text-zinc-500">投稿人:</span>
<span class="text-zinc-300">
{{ song.requester.name }}
<span class="text-zinc-500 ml-1">
{{ song.requester.grade }}{{ song.requester.class }}
</span>
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</transition>
</div>
</transition>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
import { useSemesters } from '~/composables/useSemesters'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
userId: {
type: Number,
default: null
}
})
const emit = defineEmits(['close'])
// Data
const loading = ref(false)
const error = ref('')
const userSongs = ref(null)
const activeTab = ref('submitted')
const selectedSemester = ref(null)
// Composables
const { semesters, fetchSemesterOptions } = useSemesters()
// Computed
const semesterOptions = computed(() => {
return semesters.value.map((s) => ({
label: s.name,
value: s.name
}))
})
const tabs = [
{ id: 'submitted', label: '投稿歌曲' },
{ id: 'voted', label: '投票歌曲' },
{ id: 'replay', label: '重播申请' }
]
const activeTabIcon = computed(() => {
switch (activeTab.value) {
case 'submitted':
return 'music'
case 'voted':
return 'heart'
case 'replay':
return 'repeat'
default:
return 'file-text'
}
})
const currentSongs = computed(() => {
if (!userSongs.value) return []
switch (activeTab.value) {
case 'submitted':
return userSongs.value.submittedSongs || []
case 'voted':
return userSongs.value.votedSongs || []
case 'replay':
return userSongs.value.replayRequestedSongs || []
default:
return []
}
})
const filteredSongs = computed(() => {
let songs = currentSongs.value
if (selectedSemester.value) {
songs = songs.filter((song) => song.semester === selectedSemester.value)
}
return songs
})
// Methods
const getTabCount = (tabId) => {
if (!userSongs.value) return 0
let songs = []
switch (tabId) {
case 'submitted':
songs = userSongs.value.submittedSongs || []
break
case 'voted':
songs = userSongs.value.votedSongs || []
break
case 'replay':
songs = userSongs.value.replayRequestedSongs || []
break
default:
return 0
}
if (selectedSemester.value) {
songs = songs.filter((song) => song.semester === selectedSemester.value)
}
return songs.length
}
const fetchUserSongs = async () => {
if (!props.userId) return
loading.value = true
error.value = ''
try {
const auth = useAuth()
// 并行获取歌曲数据和学期列表
const promises = [$fetch(`/api/admin/users/${props.userId}/songs`, { ...auth.getAuthConfig() })]
// 如果学期列表为空,尝试获取
if (semesters.value.length === 0) {
promises.push(fetchSemesterOptions())
}
const [songsResponse] = await Promise.all(promises)
userSongs.value = songsResponse
} catch (err) {
console.error('获取用户歌曲信息失败:', err)
error.value = err.data?.message || '获取用户歌曲信息失败'
} finally {
loading.value = false
}
}
const retryFetch = () => {
fetchUserSongs()
}
const handleOverlayClick = () => {
emit('close')
}
// Helpers
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 86400000 * 7) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'short',
day: 'numeric'
})
}
const getMetaTime = (song) => {
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} 投票`
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} 申请`
return ''
}
const getStatusText = (song) => {
if (song.played) return '已播放'
if (song.scheduled) return '已排期'
return '待排期'
}
const getStatusClasses = (song) => {
if (song.played) {
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
}
if (song.scheduled) {
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
}
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}
// 监听器
watch(
() => props.userId,
(newUserId) => {
if (newUserId && props.show) {
fetchUserSongs()
}
}
)
watch(
() => props.show,
(newShow) => {
if (newShow && props.userId) {
fetchUserSongs()
} else if (!newShow) {
// 关闭时重置状态
userSongs.value = null
error.value = ''
activeTab.value = 'submitted'
selectedSemester.value = null // 可选:关闭时重置过滤器
}
}
)
</script>
<style scoped>
/* 列表的自定义滚动条 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(113, 113, 122, 0.3); /* zinc-500/30 */
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(113, 113, 122, 0.5);
}
/* 标签页隐藏滚动条 */
.no-scrollbar::-webkit-scrollbar {
display: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
</style>
+533
View File
@@ -0,0 +1,533 @@
<template>
<div v-if="show" class="modal-overlay" @click="closeModal">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h3 class="modal-title">投票人员列表</h3>
<button class="close-btn" @click="closeModal">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</svg>
</button>
</div>
<div class="modal-body">
<!-- 歌曲信息 -->
<div v-if="songInfo" class="song-info">
<h4 class="song-title">{{ songInfo.title }}</h4>
<p class="song-artist">{{ songInfo.artist }}</p>
<div class="vote-summary">
<svg class="heart-icon" fill="currentColor" viewBox="0 0 24 24">
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
/>
</svg>
<span class="vote-count">{{ totalVotes }} 票</span>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="spinner" />
<p>正在加载投票人员...</p>
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error-container">
<svg
class="error-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<circle cx="12" cy="12" r="10" />
<line x1="15" x2="9" y1="9" y2="15" />
<line x1="9" x2="15" y1="9" y2="15" />
</svg>
<p class="error-message">{{ error }}</p>
<button class="retry-btn" @click="fetchVoters">重试</button>
</div>
<!-- 投票人员列表 -->
<div v-else-if="voters.length > 0" class="voters-list">
<div class="voters-header">
<span class="voters-title">投票人员 ({{ voters.length }})</span>
</div>
<div class="voters-container">
<div v-for="(voter, index) in voters" :key="voter.id" class="voter-item">
<div class="voter-info">
<div class="voter-avatar">
{{ getAvatarText(voter.name) }}
</div>
<div class="voter-details">
<span class="voter-name">{{ voter.name }}</span>
<span class="vote-time">{{ formatVoteTime(voter.votedAt) }}</span>
</div>
</div>
<div class="voter-number">#{{ index + 1 }}</div>
</div>
</div>
</div>
<!-- 无投票状态 -->
<div v-else class="empty-state">
<svg
class="empty-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
/>
</svg>
<p>暂无投票</p>
</div>
</div>
<div class="modal-footer">
<button class="close-button" @click="closeModal">关闭</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
// Props
const props = defineProps({
show: {
type: Boolean,
default: false
},
songId: {
type: Number,
default: null
}
})
// Emits
const emit = defineEmits(['close'])
// 响应式数据
const loading = ref(false)
const error = ref('')
const songInfo = ref(null)
const voters = ref([])
const totalVotes = ref(0)
// 方法
const closeModal = () => {
emit('close')
}
const fetchVoters = async () => {
if (!props.songId) return
loading.value = true
error.value = ''
try {
const response = await $fetch(`/api/songs/${props.songId}/voters`)
songInfo.value = response.song
voters.value = response.voters || []
totalVotes.value = response.totalVotes || 0
} catch (err) {
console.error('获取投票人员失败:', err)
error.value = err.data?.message || '获取投票人员失败'
} finally {
loading.value = false
}
}
const getAvatarText = (name) => {
if (!name) return '?'
// 提取中文姓名的最后一个字符,或英文名的首字母
const cleanName = name.replace(/[((].*[))]/, '').trim()
return cleanName.slice(-1).toUpperCase()
}
const formatVoteTime = (dateString) => {
const date = new Date(dateString)
const now = new Date()
const diff = now - date
if (diff < 60000) return '刚刚'
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
return date.toLocaleDateString('zh-CN', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
// 监听弹窗显示状态
watch(
() => props.show,
(newShow) => {
if (newShow && props.songId) {
fetchVoters()
} else {
// 重置数据
songInfo.value = null
voters.value = []
totalVotes.value = 0
error.value = ''
}
}
)
</script>
<style scoped>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.modal-content {
background: #1a1a1a;
border-radius: 16px;
border: 1px solid #2a2a2a;
width: 100%;
max-width: 500px;
max-height: 80vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: #ffffff;
margin: 0;
}
.close-btn {
background: none;
border: none;
color: #888;
cursor: pointer;
padding: 8px;
border-radius: 8px;
transition: all 0.2s ease;
}
.close-btn:hover {
background: #2a2a2a;
color: #ffffff;
}
.close-btn svg {
width: 20px;
height: 20px;
}
.modal-body {
flex: 1;
padding: 24px;
overflow-y: auto;
}
.song-info {
background: #2a2a2a;
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid #3a3a3a;
}
.song-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
margin: 0 0 8px 0;
}
.song-artist {
color: #888;
margin: 0 0 16px 0;
font-size: 14px;
}
.vote-summary {
display: flex;
align-items: center;
gap: 8px;
}
.heart-icon {
width: 20px;
height: 20px;
color: #ef4444;
}
.vote-count {
color: #ef4444;
font-weight: 600;
font-size: 16px;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #3a3a3a;
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.error-icon {
width: 48px;
height: 48px;
color: #ef4444;
}
.error-message {
color: #ef4444;
text-align: center;
margin: 0;
}
.retry-btn {
background: #667eea;
color: white;
border: none;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background 0.2s ease;
}
.retry-btn:hover {
background: #5a67d8;
}
.voters-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.voters-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #3a3a3a;
}
.voters-title {
font-weight: 600;
color: #ffffff;
font-size: 16px;
}
.voters-container {
display: flex;
flex-direction: column;
gap: 12px;
max-height: 300px;
overflow-y: auto;
}
.voter-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #2a2a2a;
border-radius: 8px;
border: 1px solid #3a3a3a;
transition: all 0.2s ease;
}
.voter-item:hover {
background: #333333;
border-color: #4a4a4a;
}
.voter-info {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.voter-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 14px;
}
.voter-details {
display: flex;
flex-direction: column;
gap: 2px;
}
.voter-name {
color: #ffffff;
font-weight: 500;
font-size: 14px;
}
.vote-time {
color: #888;
font-size: 12px;
}
.voter-number {
color: #888;
font-size: 12px;
font-weight: 500;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
gap: 16px;
}
.empty-icon {
width: 48px;
height: 48px;
color: #666;
}
.empty-state p {
color: #888;
margin: 0;
}
.modal-footer {
padding: 0 24px 24px;
display: flex;
justify-content: flex-end;
}
.close-button {
background: #3a3a3a;
color: #ffffff;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: background 0.2s ease;
}
.close-button:hover {
background: #4a4a4a;
}
/* 滚动条样式 */
.voters-container::-webkit-scrollbar {
width: 6px;
}
.voters-container::-webkit-scrollbar-track {
background: #2a2a2a;
border-radius: 3px;
}
.voters-container::-webkit-scrollbar-thumb {
background: #4a4a4a;
border-radius: 3px;
}
.voters-container::-webkit-scrollbar-thumb:hover {
background: #5a5a5a;
}
/* 响应式设计 */
@media (max-width: 768px) {
.modal-overlay {
padding: 10px;
}
.modal-content {
max-height: 90vh;
}
.modal-header,
.modal-body,
.modal-footer {
padding-left: 16px;
padding-right: 16px;
}
.voter-item {
padding: 10px 12px;
}
.voter-avatar {
width: 32px;
height: 32px;
font-size: 12px;
}
}
</style>
+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>
+363
View File
@@ -0,0 +1,363 @@
<template>
<div class="space-y-6">
<form class="space-y-5" @submit.prevent="handleChangePassword">
<div v-if="!isFirstLogin" class="space-y-2">
<label
for="current-password"
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
>当前密码</label
>
<div class="relative group">
<div
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
>
<Lock :size="18" />
</div>
<input
id="current-password"
v-model="currentPassword"
:class="[
inputClass,
error
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
]"
:type="showCurrentPassword ? 'text' : 'password'"
placeholder="请输入当前密码"
required
@input="error = ''"
>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
type="button"
@click="showCurrentPassword = !showCurrentPassword"
>
<Eye v-if="!showCurrentPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
</div>
<div class="space-y-2">
<label
for="new-password"
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
>新密码</label
>
<div class="relative group">
<div
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
>
<KeyRound :size="18" />
</div>
<input
id="new-password"
v-model="newPassword"
:class="[
inputClass,
error
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
]"
:type="showNewPassword ? 'text' : 'password'"
placeholder="请输入新密码"
required
@input="
error = '';
validatePassword()
"
>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
type="button"
@click="showNewPassword = !showNewPassword"
>
<Eye v-if="!showNewPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<!-- 密码强度指示器 -->
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
<div class="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500"
:class="passwordStrength.colorClass"
:style="{ width: passwordStrength.width }"
/>
</div>
<div class="flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-zinc-500"
>密码强度</span
>
<span
class="text-[10px] font-black uppercase tracking-widest"
:class="passwordStrength.textColorClass"
>
{{ passwordStrength.text }}
</span>
</div>
</div>
</div>
<div class="space-y-2">
<label
for="confirm-password"
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
>确认新密码</label
>
<div class="relative group">
<div
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
>
<CheckCircle2 :size="18" />
</div>
<input
id="confirm-password"
v-model="confirmPassword"
:class="[
inputClass,
error || (confirmPassword && newPassword !== confirmPassword)
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
]"
:type="showConfirmPassword ? 'text' : 'password'"
placeholder="请再次输入新密码"
required
@input="error = ''"
>
<button
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
type="button"
@click="showConfirmPassword = !showConfirmPassword"
>
<Eye v-if="!showConfirmPassword" :size="18" />
<EyeOff v-else :size="18" />
</button>
</div>
<!-- 密码匹配提示 -->
<div v-if="confirmPassword" class="px-1">
<div
v-if="newPassword !== confirmPassword"
class="flex items-center gap-1.5 text-rose-500"
>
<XCircle :size="12" />
<span class="text-[10px] font-bold">密码不匹配</span>
</div>
<div v-else class="flex items-center gap-1.5 text-emerald-500">
<CheckCircle2 :size="12" />
<span class="text-[10px] font-bold">密码匹配</span>
</div>
</div>
</div>
<!-- 状态消息 -->
<div
v-if="error"
class="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3"
>
<AlertCircle :size="16" class="text-rose-500 shrink-0" />
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
</div>
<div
v-if="success"
class="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3"
>
<CheckCircle2 :size="16" class="text-emerald-500 shrink-0" />
<span class="text-xs text-emerald-500 font-medium">{{ success }}</span>
</div>
<button
:disabled="loading || !isFormValid"
class="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-[0.98] disabled:opacity-50"
type="submit"
>
<Loader2 v-if="loading" :size="18" class="animate-spin" />
<span>{{ loading ? '处理中...' : isFirstLogin ? '设置初始密码' : '确认修改密码' }}</span>
</button>
</form>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import {
Lock,
KeyRound,
Eye,
EyeOff,
CheckCircle2,
XCircle,
AlertCircle,
Loader2
} from '@lucide/vue'
// 组件属性
const props = defineProps({
isFirstLogin: {
type: Boolean,
default: false
}
})
const auth = useAuth()
const router = useRouter()
const currentPassword = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const error = ref('')
const success = ref('')
const loading = ref(false)
// 样式类
const inputClass =
'w-full bg-zinc-950 border rounded-xl pl-11 pr-11 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 focus:outline-none transition-all'
// 密码显示状态
const showCurrentPassword = ref(false)
const showNewPassword = ref(false)
const showConfirmPassword = ref(false)
// 密码强度计算
const passwordStrength = computed(() => {
const password = newPassword.value
if (!password) return { width: '0%', colorClass: '', textColorClass: '', text: '' }
let score = 0
if (password.length >= 8) score += 25
if (/[A-Z]/.test(password)) score += 25
if (/[a-z]/.test(password)) score += 25
if (/[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password)) score += 25
if (score < 50) {
return {
width: `${score || 10}%`,
colorClass: 'bg-rose-500',
textColorClass: 'text-rose-500',
text: '弱'
}
} else if (score < 75) {
return {
width: `${score}%`,
colorClass: 'bg-amber-500',
textColorClass: 'text-amber-500',
text: '中等'
}
} else if (score < 100) {
return {
width: `${score}%`,
colorClass: 'bg-blue-500',
textColorClass: 'text-blue-500',
text: '强'
}
} else {
return {
width: '100%',
colorClass: 'bg-emerald-500',
textColorClass: 'text-emerald-500',
text: '极强'
}
}
})
// 表单验证
const isFormValid = computed(() => {
if (props.isFirstLogin) {
return (
newPassword.value &&
confirmPassword.value &&
newPassword.value === confirmPassword.value &&
newPassword.value.length >= 8
)
} else {
return (
currentPassword.value &&
newPassword.value &&
confirmPassword.value &&
newPassword.value === confirmPassword.value &&
newPassword.value.length >= 8
)
}
})
const validatePassword = () => {
if (newPassword.value && newPassword.value.length < 8) {
error.value = '密码长度至少为8位'
} else {
error.value = ''
}
}
const handleChangePassword = async () => {
if (newPassword.value !== confirmPassword.value) {
error.value = '新密码和确认密码不匹配'
return
}
if (newPassword.value.length < 8) {
error.value = '新密码长度至少为8位'
return
}
loading.value = true
error.value = ''
success.value = ''
try {
if (props.isFirstLogin) {
await auth.setInitialPassword(newPassword.value)
success.value = '密码设置成功!正在跳转...'
// 清空表单
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// 密码设置完成后跳转
setTimeout(async () => {
// 更新用户状态
await auth.refreshUser()
if (auth.isAdmin.value) {
router.push('/dashboard')
} else {
router.push('/')
}
}, 2000)
} else {
await auth.changePassword(currentPassword.value, newPassword.value)
success.value = '密码修改成功!请重新登录'
// 清空表单
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
// 密码修改后登出
setTimeout(() => {
auth.logout()
router.push('/login')
}, 2000)
}
} catch (err) {
// 提取错误信息,支持多种错误格式
if (err.data && err.data.statusMessage) {
error.value = err.data.statusMessage
} else if (err.data && err.data.message) {
error.value = err.data.message
} else if (err.statusMessage) {
error.value = err.statusMessage
} else if (err.message) {
error.value = err.message
} else {
error.value = '操作失败,请重试'
}
} finally {
loading.value = false
}
}
</script>
+674
View File
@@ -0,0 +1,674 @@
<template>
<div class="login-form">
<div class="form-header">
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
<p v-else>登录您的VoiceHub账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
<div class="form-group">
<label for="username">姓名/用户名</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<input
id="username"
v-model="username"
:class="{ 'input-error': error }"
placeholder="请输入姓名/用户名"
required
type="text"
@input="error = ''"
>
</div>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
<circle cx="12" cy="16" r="1" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<input
id="password"
v-model="password"
:class="{ 'input-error': error }"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
required
@input="error = ''"
>
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
<svg
v-if="showPassword"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
/>
<line x1="1" x2="23" y1="1" y2="23" />
</svg>
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<div v-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="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<span class="error-message">{{ error }}</span>
</div>
<button :disabled="loading" class="submit-btn" type="submit">
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
fill="none"
r="10"
stroke="currentColor"
stroke-dasharray="31.416"
stroke-dashoffset="31.416"
stroke-linecap="round"
stroke-width="2"
>
<animate
attributeName="stroke-dasharray"
dur="2s"
repeatCount="indefinite"
values="0 31.416;15.708 15.708;0 31.416"
/>
<animate
attributeName="stroke-dashoffset"
dur="2s"
repeatCount="indefinite"
values="0;-15.708;-31.416"
/>
</circle>
</svg>
<span v-if="loading">{{ isBindMode ? '绑定中...' : '登录中...' }}</span>
<span v-else>{{ isBindMode ? '绑定并登录' : '登录' }}</span>
</button>
</form>
<div v-if="!isBindMode && isWebAuthnSupported" class="webauthn-section">
<div class="divider">
<span>或</span>
</div>
<button
type="button"
class="webauthn-btn"
:disabled="loading"
@click="handleWebAuthnLogin"
>
<Fingerprint :size="20" class="webauthn-icon" />
<span>使用 Windows Hello / Passkey 登录</span>
</button>
</div>
<AuthOAuthButtons v-if="!isBindMode" />
<div class="form-footer">
<p class="help-text">不同VoiceHub平台的账号不互通</p>
<div style="margin-top: 16px;">
<NuxtLink to="/register" class="register-link-btn">
还没有账号?去注册
</NuxtLink>
</div>
</div>
<AuthTwoFactorVerify
:show="show2FA"
:user-id="userId2FA"
:available-methods="methods2FA"
:masked-email="maskedEmail2FA"
:temp-token="tempToken2FA"
@success="handle2FASuccess"
@cancel="show2FA = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useAuth } from '~/composables/useAuth'
import { getProviderDisplayName } from '~/utils/oauth'
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
import { Fingerprint } from '@lucide/vue'
const route = useRoute()
const isBindMode = computed(() => route.query.action === 'bind')
const providerUsername = computed(() => route.query.username || '')
const providerName = computed(() => {
const provider = (route.query.provider as string) || '第三方'
return getProviderDisplayName(provider)
})
const username = ref('')
const password = ref('')
const error = ref('')
const loading = ref(false)
const showPassword = ref(false)
const isWebAuthnSupported = ref(false)
const show2FA = ref(false)
const userId2FA = ref(0)
const methods2FA = ref<string[]>([])
const tempToken2FA = ref('')
const maskedEmail2FA = ref('')
const auth = useAuth()
const handle2FASuccess = async () => {
if (auth.isAdmin.value) {
await navigateTo('/dashboard')
} else {
await navigateTo('/')
}
}
onMounted(async () => {
const isApiSupported = browserSupportsWebAuthn()
let isPlatformAuthenticatorAvailable = false
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch (e) {
console.warn('WebAuthn 平台认证器检查失败:', e)
}
}
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
isWebAuthnSupported.value = isApiSupported
})
const handleLogin = async () => {
if (!username.value || !password.value) {
error.value = '请填写完整的登录信息'
return
}
error.value = ''
loading.value = true
try {
if (isBindMode.value) {
await $fetch('/api/auth/bind', {
method: 'POST',
body: {
username: username.value,
password: password.value
}
})
// 绑定成功后刷新认证状态
await auth.initAuth()
await navigateTo('/')
} else {
const response = await auth.login(username.value, password.value)
if (response.requires2FA) {
userId2FA.value = response.userId
methods2FA.value = response.methods
tempToken2FA.value = response.tempToken
maskedEmail2FA.value = response.maskedEmail || ''
show2FA.value = true
return
}
// 登录成功后跳转
if (auth.isAdmin.value) {
await navigateTo('/dashboard')
} else {
await navigateTo('/')
}
}
} catch (err) {
error.value =
err.message || (isBindMode.value ? '绑定失败,请检查账号密码' : '登录失败,请检查账号密码')
// 密码错误时清空密码字段
if (error.value.includes('密码') || error.value.includes('错误')) {
password.value = ''
}
} finally {
loading.value = false
}
}
const handleWebAuthnLogin = async () => {
loading.value = true
error.value = ''
try {
// 1. 获取登录选项
const options = await $fetch('/api/auth/webauthn/login/options', {
method: 'POST'
})
// 2. 调用浏览器 WebAuthn API
const credential = await startAuthentication(options)
// 3. 验证登录
const verification = await $fetch('/api/auth/webauthn/login/verify', {
method: 'POST',
body: credential
})
if (verification.success) {
// 登录成功
await auth.initAuth()
await navigateTo(verification.redirect || '/')
}
} catch (e) {
console.error('WebAuthn 登录错误:', e)
const apiError = e as { data?: { message?: string }, message?: string }
error.value = apiError.data?.message || apiError.message || 'Passkey 登录失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-form {
width: 100%;
max-width: 400px;
animation: fadeInUp 0.4s ease both;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.form-header {
text-align: center;
margin-bottom: 32px;
}
.form-header h2 {
font-size: 28px;
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0 0 8px 0;
}
.form-header p {
font-size: 16px;
color: var(--text-tertiary);
margin: 0;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-wrapper::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 8px;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
transform: scaleX(0.2);
transition:
transform var(--transition-normal),
opacity var(--transition-normal);
}
.input-wrapper:focus-within::after {
opacity: 0.35;
transform: scaleX(1);
}
.input-icon {
position: absolute;
left: 16px;
width: 20px;
height: 20px;
color: var(--text-quaternary);
z-index: 1;
}
.input-wrapper input {
width: 100%;
padding: 16px 16px 16px 48px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
font-size: 16px;
transition:
border-color var(--transition-normal),
box-shadow var(--transition-normal);
}
.input-wrapper input::placeholder {
color: var(--input-placeholder);
}
.input-wrapper input:focus {
outline: none;
border-color: var(--input-border-focus);
box-shadow: var(--input-shadow-focus);
}
.input-wrapper input:focus + .input-icon,
.input-wrapper input:not(:placeholder-shown) + .input-icon {
color: var(--primary);
}
.input-wrapper input:hover {
filter: brightness(1.03);
}
.input-wrapper input.input-error {
border-color: var(--error);
box-shadow: 0 0 0 3px var(--error-light);
}
.password-toggle {
position: absolute;
right: 16px;
width: 20px;
height: 20px;
background: none;
border: none;
color: var(--text-quaternary);
cursor: pointer;
transition:
color 0.2s ease,
transform var(--transition-fast);
z-index: 1;
}
.password-toggle:hover {
color: var(--text-primary);
}
.password-toggle:active {
transform: scale(0.95);
}
.password-toggle svg {
width: 100%;
height: 100%;
}
.error-container {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--error-light);
border: 1px solid var(--error-border);
border-radius: var(--radius-lg);
color: var(--error);
}
.auth-form.has-error {
animation: shake 0.4s ease;
}
@keyframes shake {
0% {
transform: translateX(0);
}
15% {
transform: translateX(-6px);
}
30% {
transform: translateX(6px);
}
45% {
transform: translateX(-4px);
}
60% {
transform: translateX(4px);
}
75% {
transform: translateX(-2px);
}
90% {
transform: translateX(2px);
}
100% {
transform: translateX(0);
}
}
.error-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.error-message {
font-size: 14px;
font-weight: var(--font-medium);
}
.submit-btn {
width: 100%;
padding: 16px;
background: var(--btn-primary-bg);
color: var(--btn-primary-text);
border: 1px solid var(--btn-primary-border);
border-radius: var(--radius-lg);
font-size: 16px;
font-weight: var(--font-semibold);
cursor: pointer;
transition:
background var(--transition-normal),
box-shadow var(--transition-normal),
transform var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
overflow: hidden;
}
.submit-btn::before {
content: none;
}
.submit-btn:hover:not(:disabled) {
background: var(--btn-primary-hover);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
.loading-spinner {
width: 20px;
height: 20px;
}
.form-footer {
margin-top: 24px;
text-align: center;
}
.help-text {
font-size: 12px;
color: var(--text-quaternary);
margin: 0;
line-height: 1.5;
}
.help-text code {
background: var(--input-bg);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: var(--primary);
font-size: 11px;
}
.register-link-btn {
display: inline-block;
width: 100%;
padding: 14px;
border-radius: var(--radius-lg);
font-size: 16px;
font-weight: var(--font-semibold);
color: var(--primary);
background-color: var(--primary-opacity-10, rgba(59, 130, 246, 0.1));
text-decoration: none;
transition: all 0.2s ease;
}
.register-link-btn:hover {
background-color: var(--primary-opacity-20, rgba(59, 130, 246, 0.2));
transform: translateY(-1px);
}
.webauthn-section {
width: 100%;
}
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 20px 0;
color: var(--text-quaternary);
font-size: 12px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--input-border);
}
.divider span {
padding: 0 10px;
}
.webauthn-btn {
width: 100%;
padding: 14px;
background: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
font-size: 15px;
font-weight: var(--font-medium);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.webauthn-btn:hover:not(:disabled) {
background: var(--surface-tertiary);
border-color: var(--input-border-focus);
}
.webauthn-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.webauthn-icon {
width: 20px;
height: 20px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.form-header h2 {
font-size: 24px;
}
.form-header p {
font-size: 14px;
}
.input-wrapper input {
padding: 14px 14px 14px 44px;
font-size: 16px; /* 防止iOS缩放 */
}
.submit-btn {
padding: 14px;
font-size: 16px;
}
}
</style>
+496
View File
@@ -0,0 +1,496 @@
<template>
<div class="space-y-6">
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
<Loader2 :size="24" class="text-blue-500 animate-spin mb-3" />
<p class="text-zinc-500 text-xs font-medium">加载绑定状态...</p>
</div>
<div v-else class="space-y-4">
<!-- GitHub -->
<div v-if="config.public.oauth.github" :class="itemClass">
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
>
<AuthProvidersGitHubIcon class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{
getProviderDisplayName('github')
}}</span>
<span v-if="githubIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
githubIdentity.providerUsername
}}</span>
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
</div>
</div>
<button
v-if="githubIdentity"
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
:disabled="actionLoading"
@click="confirmUnbind('github')"
>
{{ actionLoading ? '处理中...' : '解绑' }}
</button>
<button
v-else
class="px-4 py-1.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 disabled:opacity-50"
:disabled="actionLoading"
@click="handleBind('github')"
>
{{ actionLoading ? '跳转中...' : '立即绑定' }}
</button>
</div>
<!-- Casdoor (如果启用) -->
<div v-if="config.public.oauth.casdoor" :class="itemClass">
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
>
<Shield :size="20" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{
getProviderDisplayName('casdoor')
}}</span>
<span v-if="casdoorIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
casdoorIdentity.providerUsername
}}</span>
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
</div>
</div>
<button
v-if="casdoorIdentity"
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
:disabled="actionLoading"
@click="confirmUnbind('casdoor')"
>
{{ actionLoading ? '处理中...' : '解绑' }}
</button>
<button
v-else
class="px-4 py-1.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 disabled:opacity-50"
:disabled="actionLoading"
@click="handleBind('casdoor')"
>
{{ actionLoading ? '跳转中...' : '立即绑定' }}
</button>
</div>
<!-- Google (如果启用) -->
<div v-if="config.public.oauth.google" :class="itemClass">
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
>
<AuthProvidersGoogleIcon class="w-5 h-5" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{
getProviderDisplayName('google')
}}</span>
<span v-if="googleIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
googleIdentity.providerUsername
}}</span>
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
</div>
</div>
<button
v-if="googleIdentity"
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
:disabled="actionLoading"
@click="confirmUnbind('google')"
>
{{ actionLoading ? '处理中...' : '解绑' }}
</button>
<button
v-else
class="px-4 py-1.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 disabled:opacity-50"
:disabled="actionLoading"
@click="handleBind('google')"
>
{{ actionLoading ? '跳转中...' : '立即绑定' }}
</button>
</div>
<!-- WebAuthn / Passkey -->
<div
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
:class="[
itemClass,
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-zinc-900/70' : ''
]"
@click="toggleWebAuthnList"
>
<div class="flex items-center gap-4">
<div
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
>
<Fingerprint :size="20" />
</div>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-sm font-bold text-zinc-200">Windows Hello / Passkey</span>
<ChevronDown
v-if="webauthnIdentities.length > 0"
:size="14"
class="text-zinc-500 transition-transform duration-300"
:class="{ 'rotate-180': isWebAuthnExpanded }"
/>
</div>
<span class="text-[11px] text-zinc-500 mt-0.5"
>已绑定 {{ webauthnIdentities.length }} 个设备</span
>
</div>
</div>
<button
v-if="isWebAuthnSupported"
class="px-4 py-1.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 disabled:opacity-50"
:disabled="actionLoading"
@click.stop="handleWebAuthnRegister"
>
{{ actionLoading ? '处理中...' : '添加设备' }}
</button>
<div v-else-if="!isSecureContext" class="flex items-center gap-1 text-amber-500 bg-amber-500/10 px-3 py-1.5 rounded-lg border border-amber-500/20">
<AlertTriangle :size="12" />
<span class="text-[10px] font-medium">需要 HTTPS 环境</span>
</div>
</div>
<!-- WebAuthn 设备列表 -->
<Transition name="expand">
<div v-if="isWebAuthnExpanded && webauthnIdentities.length > 0" class="pl-16 -mt-2 overflow-hidden">
<div class="space-y-2 pt-2">
<div
v-for="cred in webauthnIdentities"
:key="cred.id"
class="flex items-center justify-between p-3 bg-zinc-950/20 border border-zinc-900 rounded-xl group/item"
>
<div class="flex flex-col flex-1 mr-4">
<div v-if="editingId === cred.id" class="flex items-center gap-2 mb-1">
<input
v-model="editingName"
type="text"
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 w-full"
:disabled="isRenaming"
@keyup.enter="saveEditing(cred.id)"
@keyup.esc="cancelEditing"
@click.stop
ref="editInput"
/>
</div>
<div v-else class="flex items-center gap-2 mb-0.5">
<span class="text-xs font-medium text-zinc-300">{{ cred.providerUsername }}</span>
<button
class="text-zinc-500 hover:text-zinc-300 opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
@click.stop="startEditing(cred)"
title="重命名"
>
<Pencil :size="12" />
</button>
</div>
<span class="text-[10px] text-zinc-600"
>添加于 {{ new Date(cred.createdAt).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}) }}</span
>
</div>
<div class="flex items-center gap-1">
<template v-if="editingId === cred.id">
<button
class="text-zinc-400 hover:text-green-400 transition-colors p-1"
:disabled="isRenaming"
@click.stop="saveEditing(cred.id)"
title="保存"
>
<Check :size="14" />
</button>
<button
class="text-zinc-400 hover:text-zinc-200 transition-colors p-1"
:disabled="isRenaming"
@click.stop="cancelEditing"
title="取消"
>
<X :size="14" />
</button>
</template>
<button
v-else
class="text-xs text-rose-500 hover:text-rose-400 font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
@click="confirmUnbindWebAuthn(cred)"
>
移除
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
<!-- 确认对话框 -->
<ConfirmDialog
v-model:show="showConfirmDialog"
:loading="confirmDialog.loading"
:message="confirmDialog.message"
:title="confirmDialog.title"
:type="confirmDialog.type"
@cancel="confirmDialog.onCancel"
@confirm="confirmDialog.onConfirm"
/>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed, nextTick } from 'vue'
import { Loader2, Shield, Fingerprint, ChevronDown, Pencil, Check, X, AlertTriangle } from '@lucide/vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useToast } from '~/composables/useToast'
import { getProviderDisplayName } from '~/utils/oauth'
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'
const config = useRuntimeConfig()
const { showToast } = useToast()
const identities = ref([])
const loading = ref(true)
const actionLoading = ref(false)
const isWebAuthnSupported = ref(false)
const isSecureContext = ref(true)
// 编辑相关
const editingId = ref<string | null>(null)
const editingName = ref('')
const isRenaming = ref(false)
const editInput = ref<HTMLInputElement | null>(null)
interface WebAuthnCredential {
id: string
providerUsername: string
createdAt: string
[key: string]: any
}
const startEditing = async (cred: WebAuthnCredential) => {
editingId.value = cred.id
editingName.value = cred.providerUsername
// 聚焦输入框
await nextTick()
if (editInput.value) {
(editInput.value as HTMLInputElement)?.focus()
}
}
const cancelEditing = () => {
editingId.value = null
editingName.value = ''
}
const saveEditing = async (id: string) => {
if (!editingName.value.trim()) {
showToast('设备名称不能为空', 'error')
return
}
if (editingName.value.trim().length > 50) {
showToast('设备名称过长 (最大50个字符)', 'error')
return
}
isRenaming.value = true
try {
await $fetch('/api/auth/webauthn/rename', {
method: 'POST',
body: { id, name: editingName.value }
})
showToast('设备名称修改成功', 'success')
await fetchIdentities()
cancelEditing()
} catch (e: any) {
showToast(e.data?.message || '修改失败', 'error')
} finally {
isRenaming.value = false
}
}
const isWebAuthnExpanded = ref(false)
const toggleWebAuthnList = () => {
if (webauthnIdentities.value.length > 0) {
isWebAuthnExpanded.value = !isWebAuthnExpanded.value
}
}
// 确认对话框相关
const showConfirmDialog = ref(false)
const confirmDialog = ref({
title: '',
message: '',
type: 'warning',
loading: false,
onConfirm: () => {},
onCancel: () => {}
})
// 样式类
const itemClass =
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all group'
const githubIdentity = computed(() => identities.value.find((i) => i.provider === 'github'))
const casdoorIdentity = computed(() => identities.value.find((i) => i.provider === 'casdoor'))
const googleIdentity = computed(() => identities.value.find((i) => i.provider === 'google'))
const webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
const fetchIdentities = async () => {
try {
loading.value = true
identities.value = await $fetch('/api/auth/identities')
} catch (e) {
console.error('获取绑定信息失败', e)
} finally {
loading.value = false
}
}
const handleBind = (provider) => {
actionLoading.value = true
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
navigateTo(`/api/auth/${provider}`, { external: true })
}
const confirmUnbind = (provider) => {
const providerName = getProviderDisplayName(provider)
confirmDialog.value = {
title: '解除绑定',
message: `确定要解除 ${providerName} 账号的绑定吗?解除后您将无法使用该账号登录。`,
type: 'danger',
loading: false,
onConfirm: () => handleUnbind(provider),
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const confirmUnbindWebAuthn = (cred) => {
confirmDialog.value = {
title: '移除 Passkey',
message: `确定要移除设备 "${cred.providerUsername}" 吗?移除后将无法使用该设备登录。`,
type: 'danger',
loading: false,
onConfirm: () => handleUnbind('webauthn', cred.id),
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const handleUnbind = async (provider, id = null) => {
confirmDialog.value.loading = true
actionLoading.value = true
try {
await $fetch('/api/auth/unbind', {
method: 'POST',
body: { provider, id }
})
await fetchIdentities()
showToast('解除绑定成功', 'success')
showConfirmDialog.value = false
} catch (e) {
showToast(e.data?.message || '解绑失败', 'error')
} finally {
actionLoading.value = false
confirmDialog.value.loading = false
}
}
const handleWebAuthnRegister = async () => {
if (!isWebAuthnSupported.value) {
showToast('您的浏览器不支持 Windows Hello / Passkey', 'error')
return
}
actionLoading.value = true
try {
const options = await $fetch('/api/auth/webauthn/register/options')
let attResp
try {
attResp = await startRegistration(options)
} catch (e) {
if (e.name === 'NotAllowedError') {
throw new Error('用户取消了操作')
}
throw e
}
// 提示用户输入设备名称(可选,这里先用默认的)
// attResp.label = 'Windows Hello'
await $fetch('/api/auth/webauthn/register/verify', {
method: 'POST',
body: attResp
})
showToast('设备添加成功', 'success')
await fetchIdentities()
} catch (e) {
console.error('WebAuthn 注册错误:', e)
const apiError = e as { data?: { message?: string }; message?: string }
const err = e as Error
const message = apiError.data?.message || err.message || '添加设备失败'
showToast(message, 'error')
} finally {
actionLoading.value = false
}
}
onMounted(async () => {
fetchIdentities()
isSecureContext.value = window.isSecureContext
const isApiSupported = browserSupportsWebAuthn()
let isPlatformAuthenticatorAvailable = false
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
try {
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
} catch (e) {
console.warn('WebAuthn 平台认证器检查失败:', e)
}
}
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
isWebAuthnSupported.value = isApiSupported
})
</script>
<style scoped>
.expand-enter-active,
.expand-leave-active {
transition: all 0.3s ease-in-out;
max-height: 500px;
opacity: 1;
}
.expand-enter-from,
.expand-leave-to {
max-height: 0;
opacity: 0;
}
</style>
+45
View File
@@ -0,0 +1,45 @@
<template>
<div v-if="hasEnabledProviders" class="mt-6 w-full">
<div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
<span class="px-2.5">或使用第三方账号登录</span>
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
</div>
<div class="flex justify-center gap-4">
<button
v-if="config.public.oauth.github"
type="button"
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#24292e] hover:text-white hover:border-[#24292e]"
title="使用 GitHub 登录"
@click="loginWith('github')"
>
<AuthProvidersGitHubIcon />
</button>
<button
v-if="config.public.oauth.google"
type="button"
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-white hover:text-black hover:border-[#dadce0]"
title="使用 Google 登录"
@click="loginWith('google')"
>
<AuthProvidersGoogleIcon />
</button>
<!-- 其他提供商可在此添加 -->
</div>
</div>
</template>
<script setup lang="ts">
const config = useRuntimeConfig()
const hasEnabledProviders = computed(() => {
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
})
const loginWith = (provider: string) => {
// 外部导航到 API 端点
navigateTo(`/api/auth/${provider}`, { external: true })
}
</script>
+252
View File
@@ -0,0 +1,252 @@
<template>
<div class="bg-zinc-900/40 border border-zinc-900 rounded-3xl p-6 md:p-8 shadow-2xl">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="p-2.5 bg-green-500/10 rounded-xl">
<UserCircle2 :size="20" class="text-green-500" />
</div>
<div>
<h2 class="text-base font-black text-zinc-100">个人信息设置</h2>
<p class="text-xs text-zinc-500 mt-0.5">修改您的个人资料和学校信息</p>
</div>
</div>
<form @submit.prevent="handleUpdateProfile" class="space-y-5 max-w-md">
<!-- 昵称 -->
<div class="space-y-2">
<label class="text-sm font-medium text-zinc-300">昵称</label>
<input
v-model="form.name"
type="text"
placeholder="留空则显示用户名"
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
/>
</div>
<!-- 年级与班级 -->
<div class="grid grid-cols-2 gap-4">
<div class="space-y-2">
<label class="text-sm font-medium text-zinc-300">年级</label>
<CustomSelect
v-model="form.grade"
:options="gradeOptions"
placeholder="请选择年级"
class-name="w-full custom-select-profile"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-zinc-300">班级</label>
<CustomSelect
v-model="form.class"
:options="classOptions"
placeholder="请选择班级"
class-name="w-full custom-select-profile"
/>
</div>
</div>
<!-- 学号 -->
<div class="space-y-2">
<label class="text-sm font-medium text-zinc-300">学号</label>
<input
v-model="form.studentId"
type="text"
placeholder="选填"
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
/>
</div>
<!-- 邮箱 -->
<div class="space-y-2">
<label class="text-sm font-medium text-zinc-300">邮箱</label>
<input
v-model="form.email"
type="email"
placeholder="选填"
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
/>
</div>
<div v-if="emailChangedAndNotEmpty" class="space-y-2">
<label class="text-sm font-medium text-zinc-300">邮箱验证码</label>
<div class="flex gap-2">
<input
v-model="form.emailCode"
type="text"
maxlength="6"
placeholder="输入6位数字验证码"
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all"
/>
<button
type="button"
:disabled="!form.email || emailActionLoading"
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
@click="sendProfileEmailCode"
>
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
</button>
</div>
</div>
<!-- 保存按钮 -->
<button
type="submit"
:disabled="loading || !isChanged"
class="w-full py-3.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 disabled:hover:bg-green-600 text-white rounded-xl text-sm font-black tracking-widest transition-all shadow-lg shadow-green-900/20 mt-6"
>
<span v-if="loading" class="flex items-center justify-center gap-2">
<Loader2 class="w-4 h-4 animate-spin" />
保存中...
</span>
<span v-else>保存修改</span>
</button>
</form>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue'
import { UserCircle2, Loader2 } from '@lucide/vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const auth = useAuth()
const { showToast } = useToast()
const loading = ref(false)
const emailActionLoading = ref(false)
const form = reactive({
name: '',
grade: '',
class: '',
studentId: '',
email: '',
emailCode: ''
})
const gradeOptions = [
{ label: '高一', value: '高一' },
{ label: '高二', value: '高二' },
{ label: '高三', value: '高三' }
]
const classOptions = Array.from({ length: 16 }, (_, i) => ({
label: `${i + 1}班`,
value: (i + 1).toString()
}))
// 初始数据用于比较是否被修改
const initialData = ref({})
onMounted(() => {
if (auth.user.value) {
form.name = auth.user.value.name || ''
form.grade = auth.user.value.grade || ''
form.class = auth.user.value.class || ''
form.studentId = auth.user.value.studentId || ''
form.email = auth.user.value.email || ''
form.emailCode = ''
initialData.value = { ...form }
}
})
const emailChangedAndNotEmpty = computed(() => {
const currentEmail = (form.email || '').trim().toLowerCase()
const prevEmail = (initialData.value?.email || '').trim().toLowerCase()
return currentEmail && currentEmail !== prevEmail
})
const isChanged = computed(() => {
return form.name !== initialData.value.name ||
form.grade !== initialData.value.grade ||
form.class !== initialData.value.class ||
form.studentId !== initialData.value.studentId ||
form.email !== initialData.value.email ||
(emailChangedAndNotEmpty.value && form.emailCode.length === 6)
})
const sendProfileEmailCode = async () => {
const emailStr = (form.email || '').toString().trim()
if (!emailStr) return
emailActionLoading.value = true
try {
await $fetch('/api/user/email/send-code', {
method: 'POST',
body: { email: emailStr },
...auth.getAuthConfig()
})
showToast('验证码已发送,请查收邮箱', 'success')
} catch (err) {
showToast(err?.data?.message || err?.message || '验证码发送失败', 'error')
} finally {
emailActionLoading.value = false
}
}
const handleUpdateProfile = async () => {
if (form.name) {
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(form.name)) {
showToast('昵称只能包含汉字', 'error')
return
}
}
if (emailChangedAndNotEmpty.value && !form.emailCode) {
showToast('请填写邮箱验证码', 'error')
return
}
loading.value = true
try {
const response = await $fetch('/api/user/profile', {
method: 'PUT',
body: form
})
if (response.success) {
showToast('个人信息更新成功', 'success')
initialData.value = { ...form, emailCode: '' }
form.emailCode = ''
await auth.refreshUser()
}
} catch (error) {
showToast(error.data?.message || '更新失败', 'error')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.custom-select-profile :deep(> .flex) {
padding: 10px 16px;
background-color: #09090b;
border: 1px solid #27272a;
border-radius: 0.75rem;
color: #f4f4f5;
font-size: 0.875rem;
line-height: 1.25rem;
height: 46px;
}
.custom-select-profile :deep(> .flex:hover) {
border-color: #3f3f46;
}
.custom-select-profile :deep(> .border-blue-500\/50) {
border-color: rgba(34, 197, 94, 0.5) !important;
--tw-ring-color: rgba(34, 197, 94, 0.5);
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
background-color: #09090b !important;
}
.custom-select-profile :deep(.text-\[11px\]) {
font-size: 0.875rem;
line-height: 1.25rem;
font-weight: normal;
}
</style>
@@ -0,0 +1,3 @@
<template>
<img src="https://casdoor.org/img/casdoor.png" alt="Casdoor" class="h-5 w-auto object-contain" >
</template>
@@ -0,0 +1,13 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="currentColor"
>
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</template>
@@ -0,0 +1,22 @@
<template>
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em" xmlns="http://www.w3.org/2000/svg">
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
<path
fill="#4285F4"
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
/>
<path
fill="#34A853"
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
/>
<path
fill="#FBBC05"
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
/>
<path
fill="#EA4335"
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
/>
</g>
</svg>
</template>
+720
View File
@@ -0,0 +1,720 @@
<template>
<div class="login-form">
<div class="form-header">
<h2>注册账号</h2>
<p>创建您的VoiceHub账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
<div class="form-group">
<label for="username">真实姓名(大名)</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
<input
id="username"
v-model="username"
:class="{ 'input-error': error }"
placeholder="请输入真实姓名(大名),仅限汉字"
required
type="text"
@input="error = ''"
>
</div>
</div>
<div class="form-group">
<label for="password">密码</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
<circle cx="12" cy="16" r="1" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
<input
id="password"
v-model="password"
:class="{ 'input-error': error }"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入密码"
required
@input="error = ''"
>
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
<svg
v-if="showPassword"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
/>
<line x1="1" x2="23" y1="1" y2="23" />
</svg>
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
</button>
</div>
</div>
<div class="form-row" style="display: flex; gap: 12px;">
<div class="form-group" style="flex: 1;">
<label for="grade">年级</label>
<div class="input-wrapper">
<CustomSelect
v-model="grade"
:options="gradeOptions"
placeholder="请选择年级"
class-name="w-full custom-select-override"
@update:model-value="error = ''"
/>
</div>
</div>
<div class="form-group" style="flex: 1;">
<label for="class">班级</label>
<div class="input-wrapper">
<CustomSelect
v-model="studentClass"
:options="classOptions"
placeholder="请选择班级"
class-name="w-full custom-select-override"
@update:model-value="error = ''"
/>
</div>
</div>
</div>
<div class="form-group">
<label for="studentId">学号 (选填)</label>
<div class="input-wrapper">
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
</svg>
<input
id="studentId"
v-model="studentId"
:class="{ 'input-error': error }"
placeholder="请输入学号"
type="text"
@input="error = ''"
>
</div>
</div>
<div class="form-group">
<label for="email">邮箱 (选填)</label>
<div class="input-wrapper">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M4 4h16v16H4z" />
<path d="m4 6 8 7 8-7" />
</svg>
<input
id="email"
v-model="email"
:class="{ 'input-error': error }"
placeholder="例如:name@example.com"
type="email"
@input="error = ''"
>
</div>
</div>
<div v-if="email" class="form-group">
<label for="emailCode">邮箱验证码</label>
<div class="flex gap-2" style="display: flex; gap: 8px;">
<div class="input-wrapper flex-1" style="flex: 1;">
<svg
class="input-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
</svg>
<input
id="emailCode"
v-model="emailCode"
type="text"
maxlength="6"
placeholder="6位数字验证码"
class="form-input"
:disabled="loading"
>
</div>
<button
type="button"
:disabled="!email || emailActionLoading"
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
@click="sendRegisterEmailCode"
>
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
</button>
</div>
</div>
<div v-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="12" x2="12" y1="8" y2="12" />
<line x1="12" x2="12.01" y1="16" y2="16" />
</svg>
<span class="error-message">{{ error }}</span>
</div>
<button :disabled="loading" class="submit-btn register-btn" type="submit">
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
<circle
cx="12"
cy="12"
fill="none"
r="10"
stroke="currentColor"
stroke-dasharray="31.416"
stroke-dashoffset="31.416"
stroke-linecap="round"
stroke-width="2"
>
<animate
attributeName="stroke-dasharray"
dur="2s"
repeatCount="indefinite"
values="0 31.416;15.708 15.708;0 31.416"
/>
<animate
attributeName="stroke-dashoffset"
dur="2s"
repeatCount="indefinite"
values="0;-15.708;-31.416"
/>
</circle>
</svg>
<span v-if="loading">注册中...</span>
<span v-else>注 册</span>
</button>
</form>
<div class="form-footer" style="margin-top: 12px; margin-bottom: 12px;">
<p class="help-text">
已有账号? <NuxtLink to="/login" style="color: var(--primary);">去登录</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useAuth } from '~/composables/useAuth'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
const username = ref('')
const password = ref('')
const email = ref('')
const emailCode = ref('')
const grade = ref('')
const studentClass = ref('')
const studentId = ref('')
const error = ref('')
const loading = ref(false)
const showPassword = ref(false)
const emailActionLoading = ref(false)
const pendingRedirectPath = ref('/')
const auth = useAuth()
const gradeOptions = [
{ label: '高一', value: '高一' },
{ label: '高二', value: '高二' },
{ label: '高三', value: '高三' }
]
const classOptions = Array.from({ length: 16 }, (_, i) => ({
label: `${i + 1}班`,
value: (i + 1).toString()
}))
const sendRegisterEmailCode = async () => {
if (!email.value || !email.value.trim()) {
error.value = '请先输入邮箱'
return
}
emailActionLoading.value = true
try {
await $fetch('/api/auth/email/send-code', {
method: 'POST',
body: { email: email.value.trim() }
})
if ((window as any).$showNotification) {
;(window as any).$showNotification('验证码已发送,请查收邮箱', 'success')
}
} catch (err: any) {
if ((window as any).$showNotification) {
;(window as any).$showNotification(err.data?.message || err.message || '验证码发送失败', 'error')
}
} finally {
emailActionLoading.value = false
}
}
const handleRegister = async () => {
if (!username.value || !password.value || !grade.value || !studentClass.value) {
error.value = '请填写完整的注册信息'
return
}
const chineseRegex = /^[\u4e00-\u9fa5]+$/
if (!chineseRegex.test(username.value)) {
error.value = '真实姓名(大名)只能包含汉字'
return
}
if (email.value && !emailCode.value) {
error.value = '请填写邮箱验证码'
return
}
error.value = ''
loading.value = true
try {
const response = await auth.register({
username: username.value,
password: password.value,
email: email.value,
emailCode: emailCode.value,
grade: grade.value,
class: studentClass.value,
studentId: studentId.value
})
if (response?.success) {
pendingRedirectPath.value = auth.isAdmin.value ? '/dashboard' : '/'
await navigateTo(pendingRedirectPath.value)
}
} catch (err: any) {
error.value = err.data?.message || err.message || '注册失败'
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-form {
width: 100%;
max-width: 400px;
animation: fadeInUp 0.4s ease both;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
.form-header {
text-align: center;
margin-bottom: 32px;
}
.form-header h2 {
font-size: 28px;
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0 0 8px 0;
}
.form-header p {
font-size: 16px;
color: var(--text-tertiary);
margin: 0;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
font-size: 14px;
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.input-wrapper::after {
content: '';
position: absolute;
left: 12px;
right: 12px;
bottom: 8px;
height: 2px;
background: var(--primary);
border-radius: 2px;
opacity: 0;
transform: scaleX(0.2);
transition:
transform var(--transition-normal),
opacity var(--transition-normal);
}
.input-wrapper:focus-within::after {
opacity: 0.35;
transform: scaleX(1);
}
.input-icon {
position: absolute;
left: 16px;
width: 20px;
height: 20px;
color: var(--text-quaternary);
z-index: 1;
}
.custom-select-override {
width: 100%;
}
.custom-select-override :deep(> .flex) {
padding: 14px 16px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
height: 54px;
}
.custom-select-override :deep(> .flex:hover) {
filter: brightness(1.03);
}
.custom-select-override :deep(> .border-blue-500\/50) {
border-color: var(--input-border-focus);
box-shadow: var(--input-shadow-focus);
}
.custom-select-override :deep(.text-\[11px\]) {
font-size: 16px;
font-weight: normal;
}
.custom-select-override :deep(.text-\[8px\]) {
display: none;
}
.input-wrapper input {
width: 100%;
padding: 16px 16px 16px 48px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
font-size: 16px;
transition:
border-color var(--transition-normal),
box-shadow var(--transition-normal);
}
.input-wrapper input::placeholder {
color: var(--input-placeholder);
}
.input-wrapper input:focus {
outline: none;
border-color: var(--input-border-focus);
box-shadow: var(--input-shadow-focus);
}
.input-wrapper input:focus + .input-icon,
.input-wrapper input:not(:placeholder-shown) + .input-icon {
color: var(--primary);
}
.input-wrapper input:hover {
filter: brightness(1.03);
}
.input-wrapper input.input-error {
border-color: var(--error);
box-shadow: 0 0 0 3px var(--error-light);
}
.password-toggle {
position: absolute;
right: 16px;
width: 20px;
height: 20px;
background: none;
border: none;
color: var(--text-quaternary);
cursor: pointer;
transition:
color 0.2s ease,
transform var(--transition-fast);
z-index: 1;
}
.password-toggle:hover {
color: var(--text-primary);
}
.password-toggle:active {
transform: scale(0.95);
}
.password-toggle svg {
width: 100%;
height: 100%;
}
.error-container {
display: flex;
align-items: center;
gap: 12px;
padding: 16px;
background: var(--error-light);
border: 1px solid var(--error-border);
border-radius: var(--radius-lg);
color: var(--error);
}
.auth-form.has-error {
animation: shake 0.4s ease;
}
@keyframes shake {
0% {
transform: translateX(0);
}
15% {
transform: translateX(-6px);
}
30% {
transform: translateX(6px);
}
45% {
transform: translateX(-4px);
}
60% {
transform: translateX(4px);
}
75% {
transform: translateX(-2px);
}
90% {
transform: translateX(2px);
}
100% {
transform: translateX(0);
}
}
.error-icon {
width: 20px;
height: 20px;
flex-shrink: 0;
}
.error-message {
font-size: 14px;
font-weight: var(--font-medium);
}
.submit-btn {
width: 100%;
padding: 16px;
background: var(--primary);
color: #ffffff;
border: none;
border-radius: var(--radius-lg);
font-size: 16px;
font-weight: var(--font-semibold);
cursor: pointer;
transition:
background var(--transition-normal),
box-shadow var(--transition-normal),
transform var(--transition-fast);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
position: relative;
overflow: hidden;
}
.submit-btn:hover:not(:disabled) {
background: var(--primary-dark);
box-shadow: var(--shadow-lg);
transform: translateY(-1px);
}
.submit-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.submit-btn:active:not(:disabled) {
transform: translateY(0);
}
.register-btn {
padding: 18px;
font-size: 18px;
margin-top: 8px;
}
.loading-spinner {
width: 20px;
height: 20px;
}
.form-footer {
margin-top: 24px;
text-align: center;
}
.help-text {
font-size: 12px;
color: var(--text-quaternary);
margin: 0;
line-height: 1.5;
}
.help-text code {
background: var(--input-bg);
padding: 2px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
color: var(--primary);
font-size: 11px;
}
.webauthn-section {
width: 100%;
}
.divider {
display: flex;
align-items: center;
text-align: center;
margin: 20px 0;
color: var(--text-quaternary);
font-size: 12px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--input-border);
}
.divider span {
padding: 0 10px;
}
.webauthn-btn {
width: 100%;
padding: 14px;
background: var(--surface-secondary);
color: var(--text-primary);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
font-size: 15px;
font-weight: var(--font-medium);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.webauthn-btn:hover:not(:disabled) {
background: var(--surface-tertiary);
border-color: var(--input-border-focus);
}
.webauthn-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.webauthn-icon {
width: 20px;
height: 20px;
}
/* 响应式设计 */
@media (max-width: 480px) {
.form-header h2 {
font-size: 24px;
}
.form-header p {
font-size: 14px;
}
.input-wrapper input {
padding: 14px 14px 14px 44px;
font-size: 16px; /* 防止iOS缩放 */
}
.submit-btn {
padding: 14px;
font-size: 16px;
}
}
</style>
+234
View File
@@ -0,0 +1,234 @@
<template>
<div class="space-y-6">
<!-- 状态显示 -->
<div class="flex items-center justify-between p-4 bg-zinc-950/50 rounded-xl border border-zinc-800">
<div class="flex items-center gap-3">
<div
class="p-2 rounded-lg"
:class="isEnabled ? 'bg-green-500/10 text-green-500' : 'bg-zinc-800 text-zinc-500'"
>
<ShieldCheck :size="20" />
</div>
<div>
<h3 class="font-bold text-zinc-100">双重认证 (2FA)</h3>
<p class="text-xs text-zinc-500">
{{ isEnabled ? '您的账户已开启双重认证保护' : '未开启,建议开启以提升账户安全性' }}
</p>
</div>
</div>
<button
v-if="!isEnabled"
@click="startSetup"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-lg transition-colors"
>
开启
</button>
<button
v-else
@click="confirmDisable"
class="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 text-sm font-bold rounded-lg transition-colors"
>
关闭
</button>
</div>
<!-- 开启流程 -->
<div v-if="showSetup" class="bg-zinc-950/30 rounded-xl border border-zinc-800 p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
<div class="flex items-start justify-between">
<div>
<h4 class="font-bold text-zinc-100 mb-1">设置双重认证</h4>
<p class="text-sm text-zinc-400">请使用 Google Authenticator 或其他验证器应用扫描下方二维码</p>
</div>
<button @click="cancelSetup" class="text-zinc-500 hover:text-zinc-300">
<X :size="20" />
</button>
</div>
<div class="flex flex-col md:flex-row gap-8 items-center md:items-start">
<!-- 二维码区域 -->
<div class="flex-shrink-0 bg-white p-2 rounded-lg">
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="2FA QR Code" class="w-48 h-48" />
<div v-else class="w-48 h-48 flex items-center justify-center bg-zinc-100 text-zinc-400">
<Loader2 class="animate-spin" />
</div>
</div>
<!-- 验证输入区域 -->
<div class="flex-1 space-y-4 w-full">
<div>
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
手动输入密钥
</label>
<div class="flex items-center gap-2">
<code class="px-3 py-2 bg-zinc-900 rounded-lg text-zinc-300 font-mono text-sm border border-zinc-800 select-all">
{{ secret }}
</code>
<button
@click="copySecret"
class="p-2 hover:bg-zinc-800 rounded-lg text-zinc-400 transition-colors"
title="复制密钥"
>
<Copy :size="16" />
</button>
</div>
</div>
<div>
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
验证码
</label>
<div class="flex gap-2">
<input
v-model="verificationCode"
type="text"
placeholder="请输入6位验证码"
maxlength="6"
class="flex-1 bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-2 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-mono text-center tracking-widest"
@keyup.enter="enable2FA"
/>
<button
@click="enable2FA"
:disabled="loading || verificationCode.length !== 6"
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center gap-2"
>
<Loader2 v-if="loading" class="animate-spin" :size="16" />
<span>验证并开启</span>
</button>
</div>
</div>
</div>
</div>
</div>
<UIConfirmDialog
:show="showDisableConfirm"
title="关闭双重认证"
content="关闭后,您的账户将不再受到双重认证保护。为确保安全,请输入您的登录密码以确认。"
confirm-text="验证并关闭"
confirm-type="danger"
:show-input="true"
input-placeholder="请输入当前登录密码"
input-type="password"
@confirm="disable2FA"
@cancel="showDisableConfirm = false"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ShieldCheck, X, Copy, Loader2 } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
const props = defineProps<{
initialEnabled?: boolean
}>()
const isEnabled = ref(props.initialEnabled || false)
const showSetup = ref(false)
const showDisableConfirm = ref(false)
const loading = ref(false)
const qrCodeUrl = ref('')
const secret = ref('')
const verificationCode = ref('')
const { showToast } = useToast()
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
// 修正:我们应该在组件挂载时检查状态,但目前没有专门的 check status API,
// 通常这包含在 user profile 信息中。如果 user 对象里有 has2FA 字段最好。
// 假设父组件通过 prop 传入,或者我们添加一个 check API。
// 为了稳健,我们可以复用 generate 接口之前的逻辑,或者在 login 时返回的信息里带。
// 这里的实现先依赖 props,如果需要更精确,可以加一个 api。
const startSetup = async () => {
try {
loading.value = true
const data = await $fetch('/api/user/2fa/generate', {
method: 'POST'
})
if (data) {
qrCodeUrl.value = data.qrCode
secret.value = data.secret
showSetup.value = true
verificationCode.value = ''
}
} catch (err: any) {
showToast(err.data?.message || err.message || '获取验证码失败', 'error')
} finally {
loading.value = false
}
}
const cancelSetup = () => {
showSetup.value = false
qrCodeUrl.value = ''
secret.value = ''
verificationCode.value = ''
}
const copySecret = async () => {
try {
await navigator.clipboard.writeText(secret.value)
showToast('密钥已复制', 'success')
} catch (err) {
showToast('复制失败', 'error')
}
}
const enable2FA = async () => {
if (verificationCode.value.length !== 6) return
try {
loading.value = true
const { error } = await useFetch('/api/user/2fa/enable', {
method: 'POST',
body: {
token: verificationCode.value,
secret: secret.value
}
})
if (error.value) throw error.value
showToast('双重认证已开启', 'success')
isEnabled.value = true
cancelSetup()
} catch (err: any) {
showToast(err.data?.message || err.message || '验证失败', 'error')
} finally {
loading.value = false
}
}
const confirmDisable = () => {
showDisableConfirm.value = true
}
const disable2FA = async (password: string) => {
if (!password) {
showToast('请输入密码', 'warning')
return
}
try {
loading.value = true
const { error } = await useFetch('/api/user/2fa/disable', {
method: 'POST',
body: { password }
})
if (error.value) throw error.value
showToast('双重认证已关闭', 'success')
isEnabled.value = false
showDisableConfirm.value = false
} catch (err: any) {
showToast(err.data?.message || err.message || '关闭失败', 'error')
} finally {
loading.value = false
}
}
</script>
+233
View File
@@ -0,0 +1,233 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[2000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
@click="$emit('cancel')"
>
<div
class="w-full max-w-md bg-zinc-950 border border-zinc-800 rounded-2xl p-6 shadow-2xl space-y-6"
@click.stop
>
<div class="text-center space-y-2">
<h3 class="text-xl font-bold text-zinc-100">双重认证</h3>
<p class="text-sm text-zinc-400">
{{ method === 'totp' ? '请输入您的认证器应用生成的6位验证码' : '请输入发送到您邮箱的验证码' }}
</p>
</div>
<div class="space-y-4">
<!-- 邮箱输入 (仅 Email 模式) -->
<div v-if="method === 'email'" class="space-y-2">
<label class="block text-sm text-zinc-400">
请补全您的邮箱以接收验证码
<span v-if="maskedEmail" class="block text-xs text-zinc-500 mt-1">
提示: {{ maskedEmail }}
</span>
</label>
<div class="relative">
<input
v-model="emailInput"
type="email"
placeholder="请输入完整邮箱地址"
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
:disabled="emailSent && cooldown > 0"
@keyup.enter="!emailSent && sendEmailCode()"
/>
</div>
</div>
<!-- 验证码输入 -->
<div v-if="method === 'totp' || emailSent">
<div class="relative">
<input
v-model="code"
type="text"
maxlength="6"
placeholder="000000"
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
@keyup.enter="handleVerify"
ref="inputRef"
/>
</div>
</div>
<!-- 发送邮箱验证码按钮 -->
<div v-if="method === 'email'" class="text-center">
<button
type="button"
@click="sendEmailCode"
:disabled="cooldown > 0 || sending || !emailInput"
class="text-sm text-blue-500 hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ cooldown > 0 ? `${cooldown}秒后重新发送` : (emailSent ? '重新发送验证码' : '发送验证码') }}
</button>
</div>
<div v-if="error" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500 text-sm">
<AlertCircle :size="16" />
<span>{{ error }}</span>
</div>
<button
@click="handleVerify"
:disabled="loading || code.length !== 6"
class="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
>
<Loader2 v-if="loading" class="animate-spin" :size="18" />
<span>验证登录</span>
</button>
<!-- 切换验证方式按钮 -->
<button
v-if="hasMultipleMethods"
@click="toggleMethod"
class="w-full py-2 text-blue-500 hover:text-blue-400 text-sm transition-colors"
>
{{ method === 'totp' ? '无法使用验证器?切换到邮箱验证' : '使用验证器应用验证' }}
</button>
<button
@click="$emit('cancel')"
class="w-full py-2 text-zinc-500 hover:text-zinc-300 text-sm transition-colors"
>
返回登录
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
import { Loader2, AlertCircle } from '@lucide/vue'
import { useToast } from '~/composables/useToast'
const props = defineProps<{
show: boolean
userId: number
availableMethods?: string[]
maskedEmail?: string
tempToken?: string
}>()
const emit = defineEmits<{
(e: 'success', data: any): void
(e: 'cancel'): void
}>()
const { showToast } = useToast()
// 默认优先使用 TOTP,如果没有则使用 Email
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
const method = ref(defaultMethod.value)
const methods = computed(() => props.availableMethods || ['totp', 'email'])
const hasMultipleMethods = computed(() => methods.value.length > 1)
const code = ref('')
const emailInput = ref('')
const emailSent = ref(false)
const loading = ref(false)
const sending = ref(false)
const cooldown = ref(0)
const error = ref('')
const inputRef = ref<HTMLInputElement | null>(null)
let timer: NodeJS.Timeout | undefined
onUnmounted(() => {
if (timer) {
clearInterval(timer)
timer = undefined
}
})
const toggleMethod = () => {
method.value = method.value === 'totp' ? 'email' : 'totp'
// 切换回 totp 时清空邮箱输入
if (method.value === 'totp') {
emailInput.value = ''
emailSent.value = false
}
}
const sendEmailCode = async () => {
try {
if (!emailInput.value) {
error.value = '请输入您的邮箱地址'
return
}
sending.value = true
error.value = ''
await $fetch('/api/auth/2fa/send-email', {
method: 'POST',
body: {
userId: props.userId,
token: props.tempToken,
email: emailInput.value
}
})
showToast('验证码已发送', 'success')
emailSent.value = true
cooldown.value = 60
timer = setInterval(() => {
cooldown.value--
if (cooldown.value <= 0) {
clearInterval(timer)
timer = undefined
}
}, 1000)
// 发送成功后自动聚焦验证码输入框
nextTick(() => inputRef.value?.focus())
} catch (err: any) {
error.value = err.data?.message || err.message || '发送失败'
} finally {
sending.value = false
}
}
const handleVerify = async () => {
if (code.value.length !== 6) return
try {
loading.value = true
error.value = ''
const { verify2FA } = useAuth()
const response = await verify2FA(props.userId, code.value, method.value as 'totp' | 'email', props.tempToken)
emit('success', response)
} catch (err: any) {
error.value = err.data?.message || err.message || '验证失败'
code.value = '' // 出错清空
} finally {
loading.value = false
}
}
// 切换方式时清空输入
watch(method, () => {
code.value = ''
error.value = ''
nextTick(() => inputRef.value?.focus())
})
// 显示时聚焦输入框
watch(() => props.show, (newVal) => {
if (newVal) {
// 每次重新打开时重置为默认方式(优先 TOTP)
method.value = defaultMethod.value
nextTick(() => inputRef.value?.focus())
}
})
</script>
+293
View File
@@ -0,0 +1,293 @@
<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-[2000] flex items-center justify-center p-4 sm:p-6"
@click.self="close"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div class="flex items-center justify-between p-8 pb-4">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
>
<Icon name="user" :size="24" />
</div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h3>
</div>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
<!-- 搜索输入框 -->
<div class="relative mb-6 group">
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
<Icon
name="search"
:size="20"
class="text-zinc-500 group-focus-within:text-zinc-300 transition-colors"
/>
</div>
<input
ref="searchInput"
v-model="searchQuery"
class="w-full pl-12 pr-12 py-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/30 transition-all"
placeholder="搜索用户名或姓名..."
type="text"
@input="handleSearch"
@keyup.enter="performSearch"
>
<div v-if="loading" class="absolute inset-y-0 right-4 flex items-center">
<Icon name="loader" :size="20" class="text-zinc-400 animate-spin" />
</div>
</div>
<!-- 搜索结果 -->
<div class="user-list space-y-3 min-h-[300px]">
<div
v-if="users.length === 0 && !loading && hasSearched"
class="flex flex-col items-center justify-center py-12 text-zinc-500"
>
<div
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
>
<Icon name="search" :size="32" class="opacity-20" />
</div>
<p class="text-sm font-bold uppercase tracking-widest">未找到匹配的用户</p>
</div>
<div
v-for="user in users"
:key="user.id"
:class="[
'group flex items-center p-4 rounded-2xl border transition-all cursor-pointer',
isSelected(user)
? 'bg-blue-600/10 border-blue-500/30 shadow-lg'
: 'bg-zinc-950 border-transparent hover:border-zinc-800'
]"
@click="toggleSelection(user)"
>
<!-- 头像 -->
<div
:class="[
'w-12 h-12 rounded-xl flex items-center justify-center text-lg font-black mr-4 transition-colors',
isSelected(user) ? 'bg-blue-600 text-white' : 'bg-zinc-800 text-zinc-500'
]"
>
{{ user.name.charAt(0) }}
</div>
<!-- 用户信息 -->
<div class="flex-1 min-w-0">
<div class="font-bold text-zinc-100 truncate">
{{ user.name }}
</div>
<div
class="text-[10px] font-black uppercase tracking-widest flex flex-wrap gap-2 mt-0.5 text-zinc-500"
>
<span v-if="user.grade" class="flex items-center">
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
{{ user.grade }}
</span>
<span v-if="user.class" class="flex items-center">
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
{{ user.class }}
</span>
<span class="truncate">@{{ user.username }}</span>
</div>
</div>
<!-- 选中指示器 -->
<div class="ml-4">
<div
:class="[
'w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all',
isSelected(user)
? 'bg-blue-600 border-blue-600 text-white'
: 'border-zinc-800 group-hover:border-zinc-700'
]"
>
<Icon v-if="isSelected(user)" name="check" :size="14" />
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div class="p-8 pt-0">
<div class="flex items-center justify-between gap-4">
<div class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">
<span v-if="selectedUsers.length > 0" class="text-blue-500">
已选择 {{ selectedUsers.length }} 人
</span>
</div>
<div class="flex gap-3">
<button
class="px-6 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
@click="close"
>
取消
</button>
<button
:disabled="selectedUsers.length === 0"
class="px-8 py-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest shadow-lg shadow-blue-900/20"
@click="confirm"
>
确定
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import Icon from '~/components/UI/Icon.vue'
// 简单的防抖函数实现
function useDebounceFn(fn, delay) {
let timeoutId
return (...args) => {
if (timeoutId) clearTimeout(timeoutId)
timeoutId = setTimeout(() => {
fn(...args)
}, delay)
}
}
const props = defineProps({
show: Boolean,
title: {
type: String,
default: '搜索用户'
},
multiple: {
type: Boolean,
default: true
},
excludeIds: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:show', 'select'])
const searchQuery = ref('')
const users = ref([])
const loading = ref(false)
const hasSearched = ref(false)
const selectedUsers = ref([])
const searchInput = ref(null)
const close = () => {
emit('update:show', false)
}
const isSelected = (user) => {
return selectedUsers.value.some((u) => u.id === user.id)
}
const toggleSelection = (user) => {
if (props.multiple) {
const index = selectedUsers.value.findIndex((u) => u.id === user.id)
if (index === -1) {
selectedUsers.value.push(user)
} else {
selectedUsers.value.splice(index, 1)
}
} else {
selectedUsers.value = [user]
}
}
const confirm = () => {
emit('select', props.multiple ? selectedUsers.value : selectedUsers.value[0])
close()
}
const performSearch = async () => {
if (!searchQuery.value.trim()) return
loading.value = true
hasSearched.value = true
try {
const data = await $fetch('/api/users/search', {
params: { keyword: searchQuery.value }
})
if (data.success) {
// 过滤掉排除的用户
users.value = data.users.filter((u) => !props.excludeIds.includes(u.id))
}
} catch (error) {
console.error('搜索用户失败', error)
} finally {
loading.value = false
}
}
const handleSearch = useDebounceFn(() => {
performSearch()
}, 500)
watch(
() => props.show,
(newVal) => {
if (newVal) {
searchQuery.value = ''
users.value = []
selectedUsers.value = []
hasSearched.value = false
nextTick(() => {
searchInput.value?.focus()
})
}
}
)
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>
@@ -0,0 +1,231 @@
<template>
<div class="space-y-6">
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
<Loader2 :size="24" class="text-blue-500 animate-spin mb-3" />
<p class="text-zinc-500 text-xs font-medium">加载中...</p>
</div>
<div
v-else-if="error"
class="p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center justify-between"
>
<div class="flex items-center gap-3">
<AlertCircle :size="16" class="text-rose-500" />
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
</div>
<button
class="px-3 py-1 bg-rose-500 text-white text-[10px] font-black uppercase rounded-lg hover:bg-rose-400 transition-all"
@click="fetchSettings"
>
重试
</button>
</div>
<div v-else class="space-y-4">
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">歌曲被选中通知</h3>
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲被选中安排播放时通知您</p>
</div>
<div class="shrink-0">
<input
v-model="localSettings.songSelectedNotify"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
@change="saveSettings"
>
</div>
</div>
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">歌曲已播放通知</h3>
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲被播放时通知您</p>
</div>
<div class="shrink-0">
<input
v-model="localSettings.songPlayedNotify"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
@change="saveSettings"
>
</div>
</div>
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">歌曲获得投票通知</h3>
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲获得新投票时通知您</p>
</div>
<div class="shrink-0">
<input
v-model="localSettings.songVotedNotify"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
@change="saveSettings"
>
</div>
</div>
<div
v-if="localSettings.songVotedNotify"
class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-3"
>
<div class="flex items-center justify-between">
<h3 class="text-xs font-black text-zinc-500 uppercase tracking-widest">投票通知阈值</h3>
<span class="text-xs font-bold text-blue-500"
>每获得 {{ localSettings.songVotedThreshold }} 票通知一次</span
>
</div>
<input
v-model.number="localSettings.songVotedThreshold"
type="range"
max="10"
min="1"
step="1"
class="w-full h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
@change="saveSettings"
>
<div class="flex justify-between text-[10px] font-black text-zinc-700">
<span>1</span>
<span>10</span>
</div>
</div>
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">系统通知</h3>
<p class="text-[11px] text-zinc-500 mt-0.5">接收系统公告和其他重要通知</p>
</div>
<div class="shrink-0">
<input
v-model="localSettings.systemNotify"
type="checkbox"
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
@change="saveSettings"
>
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-3">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">通知刷新间隔</h3>
<p class="text-[11px] text-zinc-500 mt-0.5">设置通知自动刷新的时间间隔</p>
</div>
<span class="text-xs font-bold text-blue-500">{{
formatRefreshInterval(localSettings.refreshInterval)
}}</span>
</div>
<input
v-model.number="localSettings.refreshInterval"
type="range"
max="300"
min="10"
step="10"
class="w-full h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
@change="saveSettings"
>
<div class="flex justify-between text-[10px] font-black text-zinc-700">
<span>10s</span>
<span>5m</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { Loader2, AlertCircle } from '@lucide/vue'
import { useNotifications } from '~/composables/useNotifications'
const notificationsService = useNotifications()
const loading = computed(() => notificationsService.loading.value)
const error = computed(() => notificationsService.error.value)
const settings = computed(() => notificationsService.settings.value)
// 样式类
const itemClass =
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all'
// 本地设置,用于双向绑定
const localSettings = ref({
songSelectedNotify: true,
songPlayedNotify: true,
songVotedNotify: true,
songVotedThreshold: 1,
systemNotify: true,
refreshInterval: 60
})
// 监听设置变化
watch(
settings,
(newSettings) => {
if (newSettings) {
localSettings.value = {
songSelectedNotify: newSettings.songSelectedNotify,
songPlayedNotify: newSettings.songPlayedNotify,
songVotedNotify: newSettings.songVotedNotify,
songVotedThreshold: newSettings.songVotedThreshold || 1,
systemNotify: newSettings.systemNotify,
refreshInterval: newSettings.refreshInterval || 60
}
}
},
{ immediate: true }
)
// 初始化
onMounted(async () => {
await fetchSettings()
})
// 获取设置
const fetchSettings = async () => {
await notificationsService.fetchNotificationSettings()
}
// 保存设置
const saveSettings = async () => {
await notificationsService.updateNotificationSettings(localSettings.value)
}
// 格式化刷新间隔
const formatRefreshInterval = (seconds) => {
if (seconds < 60) {
return `${seconds}秒`
} else {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
}
}
</script>
<style scoped>
input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #2563eb; /* blue-600 */
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
}
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
background: #2563eb;
border-radius: 50%;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
input[type='range']::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
</style>
@@ -0,0 +1,205 @@
<template>
<div
class="lyric-am"
:class="{
'align-right': settings.lyricAlignRight.value
}"
:style="{
'--amll-lp-color': 'rgb(var(--main-cover-color, 239 239 239))',
'--amll-lp-hover-bg-color': 'rgba(255,255,255,0.08)',
'--amll-lyric-left-padding': settings.lyricAlignRight.value
? ''
: `${settings.lyricHorizontalOffset.value}px`,
'--amll-lyric-right-padding': settings.lyricAlignRight.value
? `${settings.lyricHorizontalOffset.value}px`
: ''
}"
>
<Transition name="fade" mode="out-in">
<div v-if="lyricManager.loading.value" class="lyric-message-container">
<Icon name="loader" :size="32" class="spin-animation" />
<div class="message-text">歌词加载中...</div>
</div>
<div v-else-if="lyricManager.error.value" class="lyric-message-container">
<Icon name="alert-circle" :size="32" />
<div class="message-text">{{ lyricManager.error.value }}</div>
</div>
<div v-else-if="!lyricLines || lyricLines.length === 0" class="lyric-message-container">
<Icon name="music" :size="48" style="opacity: 0.5" />
<div class="message-text">暂无歌词</div>
</div>
<LyricPlayer
v-else
ref="lyricPlayerRef"
:lyric-lines="lyricLines"
:current-time="currentTime * 1000"
:playing="audioPlayer.getPlayingStatus().value"
:enable-spring="settings.useAMSpring.value"
:enable-scale="settings.useAMSpring.value"
:align-position="settings.lyricsScrollOffset.value"
:align-anchor="settings.lyricsScrollOffset.value > 0.4 ? 'center' : 'top'"
:enable-blur="settings.lyricsBlur.value"
:hide-passed-lines="settings.hidePassedLines.value"
:word-fade-width="settings.wordFadeWidth.value"
:style="{
'--display-count-down-show': settings.countDownShow.value ? 'flex' : 'none',
'--amll-lp-font-size': `${settings.lyricFontSize.value}px`,
'font-weight': settings.lyricFontWeight.value,
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : ''
}"
class="am-lyric"
@line-click="jumpSeek"
/>
</Transition>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import LyricPlayer, { type LyricPlayerRef } from '~/components/AMLL/LyricPlayer.vue'
import Icon from '~/components/UI/Icon.vue'
import { useLyricManager } from '~/composables/useLyricManager'
import { useLyricSettings } from '~/composables/useLyricSettings'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
import type { LyricLineMouseEvent } from '@applemusic-like-lyrics/core'
import { cloneDeep } from 'lodash-es'
const props = defineProps({
currentTime: {
type: Number,
default: 0
}
})
const lyricManager = useLyricManager()
const settings = useLyricSettings()
const audioPlayer = useAudioPlayer()
const audioPlayerControl = useAudioPlayerControl()
const lyricPlayerRef = ref<LyricPlayerRef | null>(null)
const lyricLines = computed(() => {
// 使用 cloneDeep 剥离 Vue 响应式代理,防止 structuredClone 错误
return cloneDeep(lyricManager.lyrics.value)
})
// 进度跳转
const jumpSeek = (line: LyricLineMouseEvent) => {
// 移除焦点,防止组件内部焦点导致键盘事件异常
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const lineContent = line.line.getLine()
if (!lineContent?.startTime) return
const time = lineContent.startTime
const offsetMs = settings.lyricOffset.value
const targetTime = (time - offsetMs) / 1000
audioPlayerControl.seekAndPlay(targetTime)
}
</script>
<style scoped>
.lyric-am {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
isolation: isolate;
}
.am-lyric {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
padding-left: var(--amll-lyric-left-padding, 10px);
padding-right: 80px;
}
/* 移动端适配 */
@media (max-width: 990px) {
.am-lyric {
padding: 0;
margin-left: 0;
}
}
.lyric-am.align-right .am-lyric {
padding-left: 80px;
padding-right: var(--amll-lyric-right-padding, 10px);
}
@media (max-width: 990px) {
.lyric-am.align-right .am-lyric {
padding: 0;
margin-right: -20px;
}
}
/* 高亮效果 */
:deep(.am-lyric .current),
:deep(.am-lyric .is-current),
:deep(.am-lyric .active),
:deep(.am-lyric .is-active),
:deep(.am-lyric .lyric-line.current),
:deep(.am-lyric .lyric-line.is-current) {
mix-blend-mode: plus-lighter;
color: rgba(255, 255, 255, 0.95);
text-shadow: 0 2px 12px rgba(255, 255, 255, 0.06);
will-change: transform, opacity, color;
}
@supports not (mix-blend-mode: plus-lighter) {
:deep(.am-lyric .current),
:deep(.am-lyric .is-current),
:deep(.am-lyric .active),
:deep(.am-lyric .is-active),
:deep(.am-lyric .lyric-line.current),
:deep(.am-lyric .lyric-line.is-current) {
color: #ffffff;
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
}
:deep(.am-lyric div[class*='lyricMainLine'] span) {
text-align: start;
}
.lyric-message-container {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--amll-lp-color, #efefef);
gap: 16px;
opacity: 0.8;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
}
.message-text {
font-size: 18px;
font-weight: 500;
letter-spacing: 0.5px;
opacity: 0.8;
}
.spin-animation {
animation: spin 1s linear infinite;
opacity: 0.9;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>
@@ -0,0 +1,829 @@
<template>
<div
:style="{
'--lrc-size': getFontSize(settings.lyricFontSize.value, settings.lyricFontSizeMode.value),
'--lrc-tran-size': getFontSize(
settings.lyricTranFontSize.value,
settings.lyricFontSizeMode.value
),
'--lrc-roma-size': getFontSize(
settings.lyricRomaFontSize.value,
settings.lyricFontSizeMode.value
),
'--lrc-bold': settings.lyricFontWeight.value,
'--lrc-left-padding': `${settings.lyricHorizontalOffset.value}px`,
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : '',
cursor: 'auto'
}"
:class="[
'lyric',
'normal',
{
'align-right': settings.lyricAlignRight.value
}
]"
@mouseleave="lrcAllLeave"
>
<div class="lyric-content">
<Transition name="fade" mode="out-in">
<div v-if="lyricManager.loading.value" class="lyric-loading">歌词正在加载中...</div>
<div v-else-if="!currentLyricData || currentLyricData.length === 0" class="lyric-loading">
暂无歌词
</div>
<div
v-else
ref="lyricScrollContainer"
class="lyric-scroll-container"
tabindex="-1"
@wheel="handleUserScroll"
@touchstart="handleUserScroll"
>
<!-- 顶部占位 -->
<div id="lrc-placeholder" class="placeholder" />
<!-- 预处理后的歌词列表 -->
<template v-for="(item, index) in processedLyrics" :key="`item-${index}`">
<!-- 倒计时行 -->
<div
v-if="item.type === 'countdown'"
class="countdown-line"
:style="{
animationPlayState: isPlaying ? 'running' : 'paused'
}"
>
<Transition name="fade" mode="out-in">
<div v-if="isCountdownVisible(item)" class="count-down">
<div
v-for="i in 3"
:key="i"
:style="{ opacity: getPointOpacity(item, i - 1) }"
class="point"
/>
</div>
</Transition>
</div>
<!-- 歌词行 -->
<div
v-else
:id="`lrc-${index}`"
:class="getLyricLineClass(item, index)"
:style="getLyricLineStyle(item, index)"
@click="jumpSeek(item.data.startTime)"
>
<!-- 逐字歌词 -->
<template v-if="isYrcMode">
<div class="content">
<div
v-for="(text, textIndex) in item.data.words"
:key="textIndex"
:class="{
'content-text': true,
'end-with-space': text.word.endsWith(' ') || text.startTime === 0
}"
:style="getYrcVars(text, index)"
>
<span class="yrc-word" :lang="getLyricLanguage(text.word)">
{{ text.word }}
</span>
</div>
</div>
</template>
<!-- 普通歌词 -->
<template v-else>
<span class="content" :lang="getLyricLanguage(item.data.words?.[0]?.word)">
{{ item.data.words.map((w) => w.word).join('') }}
</span>
</template>
<!-- 翻译和音译 -->
<template v-if="settings.swapTranRoma.value">
<!-- 音译在前 -->
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
{{ item.data.romanLyric }}
</span>
<span
v-if="item.data.translatedLyric && settings.showTranslation.value"
class="tran"
lang="en"
>
{{ item.data.translatedLyric }}
</span>
</template>
<template v-else>
<!-- 翻译在前(默认) -->
<span
v-if="item.data.translatedLyric && settings.showTranslation.value"
class="tran"
lang="en"
>
{{ item.data.translatedLyric }}
</span>
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
{{ item.data.romanLyric }}
</span>
</template>
</div>
</template>
<!-- 底部占位 -->
<div class="placeholder" />
</div>
</Transition>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
import type { LyricWord, LyricLine } from '@applemusic-like-lyrics/lyric'
import { useLyricManager } from '~/composables/useLyricManager'
import { useLyricSettings } from '~/composables/useLyricSettings'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
const props = defineProps({
currentTime: {
type: Number,
default: 0
}
})
const lyricManager = useLyricManager()
const settings = useLyricSettings()
const audioPlayer = useAudioPlayer()
const audioPlayerControl = useAudioPlayerControl()
const lyricScrollContainer = ref<HTMLElement | null>(null)
// 是否为逐字歌词模式
const isYrcMode = computed(() => {
const format = lyricManager.lyricFormat.value
return (
settings.showYrc.value &&
(format === 'qrc' || format === 'word-by-word' || format === 'enhanced')
)
})
// 获取当前使用的歌词数据
const currentLyricData = computed(() => lyricManager.lyrics.value)
const isPlaying = computed(() => audioPlayer.getPlayingStatus().value)
/** 处理后的歌词项类型 */
type ProcessedLyricItem =
| { type: 'lyric'; originalIndex: number; data: LyricLine }
| { type: 'countdown'; startTime: number; duration: number }
/** 间奏倒计时最小时长(毫秒) */
const COUNTDOWN_THRESHOLD = 10000
/** 开头倒计时最小时长(毫秒) */
const INTRO_THRESHOLD = 3000
/** 预处理歌词数据 */
const processedLyrics = computed<ProcessedLyricItem[]>(() => {
const lyrics = currentLyricData.value
if (!lyrics || lyrics.length === 0) return []
const result: ProcessedLyricItem[] = []
// 检查开头是否需要倒计时
const firstLyricStart = lyrics[0]?.startTime || 0
if (settings.countDownShow.value && firstLyricStart >= INTRO_THRESHOLD) {
result.push({
type: 'countdown',
startTime: 0,
duration: firstLyricStart
})
}
// 遍历歌词
for (let i = 0; i < lyrics.length; i++) {
const item = lyrics[i]
result.push({
type: 'lyric',
originalIndex: i,
data: item
})
// 检查是否需要插入倒计时
if (settings.countDownShow.value && i < lyrics.length - 1) {
const currentEnd = item.endTime
const nextStart = lyrics[i + 1]?.startTime || 0
const gap = nextStart - currentEnd
if (gap >= COUNTDOWN_THRESHOLD) {
result.push({
type: 'countdown',
startTime: currentEnd,
duration: gap
})
}
}
}
return result
})
/** 倒计时是否可见 */
const isCountdownVisible = (item: ProcessedLyricItem): boolean => {
if (item.type !== 'countdown') return false
// 计算实时时间 - 0.5s 是否小于开始 + 持续时间
// currentTime 是秒,转换为毫秒
return props.currentTime * 1000 + 500 < item.startTime + item.duration
}
/** 计算当前活跃的歌词行索引列表 */
const activeLineIndices = computed<number[]>(() => {
const lyrics = processedLyrics.value
if (!lyrics || lyrics.length === 0) return []
const currentSeek = props.currentTime * 1000 // 秒转毫秒
const activeCandidates: number[] = []
for (let i = 0; i < lyrics.length; i++) {
const item = lyrics[i]
let start = 0
let end = Infinity
if (item.type === 'lyric') {
start = item.data.startTime || 0
end = item.data.endTime ?? Infinity
} else {
start = item.startTime
end = item.startTime + item.duration
}
if (currentSeek >= start && currentSeek < end) {
activeCandidates.push(i)
}
}
// 如果没有活跃行,找最近的上一行
if (activeCandidates.length === 0 && currentSeek > 0) {
// 找到第一个开始时间大于当前时间的行
const nextIndex = lyrics.findIndex((item) => {
const start = item.type === 'lyric' ? item.data.startTime : item.startTime
return (start || 0) > currentSeek
})
if (nextIndex === -1) return [lyrics.length - 1] // 都在后面,取最后一行
if (nextIndex > 0) return [nextIndex - 1] // 取前一行
}
return activeCandidates
})
/** 首个高亮行索引 */
const firstActiveIndex = computed(() => {
return activeLineIndices.value[0] ?? -1
})
/** 判断某行是否高亮 */
const isLineActive = (index: number): boolean => {
return activeLineIndices.value.includes(index)
}
/**
* 计算倒计时圆点透明度
*/
const getPointOpacity = (item: ProcessedLyricItem, index: number): number => {
if (item.type !== 'countdown') return 0
const perPointTime = item.duration / 3
const currentTime = props.currentTime * 1000 - item.startTime
if (currentTime <= 0) return 0
if (currentTime < perPointTime * (index + 1)) {
const percentage = (currentTime - perPointTime * index) / perPointTime
return 0.1 + 0.7 * (1 - percentage)
}
return 0.1
}
/** 当前滚动动画 ID */
let scrollAnimationId: number | null = null
/** 用户是否正在滚动 */
const userScrolling = ref(false)
/** 用户滚动恢复超时 ID */
let userScrollTimeoutId: ReturnType<typeof setTimeout> | null = null
/** 用户滚动恢复超时时间(毫秒) */
const USER_SCROLL_TIMEOUT = 3000
/**
* 自主滚动
*/
const handleUserScroll = () => {
userScrolling.value = true
if (userScrollTimeoutId !== null) {
clearTimeout(userScrollTimeoutId)
}
// 超时后恢复
userScrollTimeoutId = setTimeout(() => {
userScrolling.value = false
userScrollTimeoutId = null
lyricsScroll(firstActiveIndex.value)
}, USER_SCROLL_TIMEOUT)
}
/**
* 平滑滚动
*/
const smoothScrollTo = (container: HTMLElement, targetY: number, duration = 300) => {
// 取消之前的动画
if (scrollAnimationId !== null) {
cancelAnimationFrame(scrollAnimationId)
scrollAnimationId = null
}
// 计算起始位置和目标位置
const startY = container.scrollTop
const diff = targetY - startY
// 如果差值很小,直接设置
if (Math.abs(diff) < 0.5) {
container.scrollTop = targetY
return
}
const startTime = performance.now()
const step = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// easeInOutQuad 缓动
const easedProgress =
progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2
container.scrollTop = startY + diff * easedProgress
if (progress < 1) {
scrollAnimationId = requestAnimationFrame(step)
} else {
scrollAnimationId = null
}
}
scrollAnimationId = requestAnimationFrame(step)
}
/**
* 歌词滚动
*/
const lyricsScroll = (index: number) => {
const container = lyricScrollContainer.value
if (!container) return
// 用户滚动时不自动滚动
if (userScrolling.value) return
const lrcItemDom = document.getElementById(index >= 0 ? `lrc-${index}` : 'lrc-placeholder')
if (!lrcItemDom) return
// 计算目标滚动位置
const containerHeight = container.clientHeight
const elementTop = lrcItemDom.offsetTop
const elementHeight = lrcItemDom.offsetHeight
// 居中偏移滚动
let targetY = elementTop - (containerHeight - elementHeight) * settings.lyricsScrollOffset.value
// 确保不超出边界
targetY = Math.max(0, Math.min(targetY, container.scrollHeight - container.clientHeight))
// 执行平滑滚动
smoothScrollTo(container, targetY, 500)
}
/**
* 鼠标移出歌词区域恢复自动滚动
*/
const lrcAllLeave = () => {
userScrolling.value = false
if (userScrollTimeoutId !== null) {
clearTimeout(userScrollTimeoutId)
userScrollTimeoutId = null
}
lyricsScroll(firstActiveIndex.value)
}
type CssVars = Record<`--${string}`, string>
/** 逐字歌词淡入淡出因子 */
const YRC_DIM_ALPHA = 0.3
/** 逐字歌词淡入淡出时间 */
const YRC_LINE_FADE_MS = 250
/** 淡入淡出行索引 */
const yrcFadingLineIndex = ref<number | null>(null)
/** 淡入淡出结束时间 */
const yrcFadingUntilAt = ref<number>(0)
const getYrcFadeFactor = (index: number): number => {
if (yrcFadingLineIndex.value !== index) return 1
const now = Date.now()
if (now >= yrcFadingUntilAt.value) return 1
const remain = yrcFadingUntilAt.value - now
return Math.min(Math.max(remain / YRC_LINE_FADE_MS, 0), 1)
}
const getYrcVars = (wordData: LyricWord, lyricIndex: number): CssVars => {
const currentSeek = props.currentTime * 1000
const fadeFactor = getYrcFadeFactor(lyricIndex)
// 判断是否显示
const currentLineItem = processedLyrics.value[lyricIndex]
if (!currentLineItem || currentLineItem.type !== 'lyric') return {}
if (!isYrcLineOn(lyricIndex)) return {}
// 计算进度
const duration = wordData.endTime - wordData.startTime
const safeDuration = Math.max(duration, 1)
const rawProgress = (currentSeek - wordData.startTime) / safeDuration
const progress = Math.min(Math.max(rawProgress, 0), 1)
const maskX = `${(1 - Math.min(progress, 1)) * 100}%`
// 计算透明度
const hasStarted = currentSeek >= wordData.startTime
const brightAlpha = hasStarted ? YRC_DIM_ALPHA + (1 - YRC_DIM_ALPHA) * fadeFactor : YRC_DIM_ALPHA
const darkAlpha = YRC_DIM_ALPHA
let transform = 'scale(1)'
if (progress > 0) {
const scale = 1 + 0.08 * progress
const y = -2 * progress
transform = `scale(${scale}) translateY(${y}px)`
}
return {
'--yrc-mask-x': maskX,
'--yrc-opacity': '1',
'--yrc-bright-alpha': `${brightAlpha}`,
'--yrc-dark-alpha': `${darkAlpha}`,
'--yrc-transform': transform
}
}
const isYrcLineOn = (index: number): boolean => {
const isActive = isLineActive(index)
const isFading = yrcFadingLineIndex.value === index && Date.now() < yrcFadingUntilAt.value
return isActive || isFading
}
const getLyricLineClass = (item: ProcessedLyricItem, index: number) => {
if (item.type !== 'lyric') return []
const lyricData = item.data
const isOn = isYrcMode.value ? isYrcLineOn(index) : isLineActive(index)
return [
'lrc-line',
isYrcMode.value ? 'is-yrc' : 'is-lrc',
{
on: isOn,
'is-bg': lyricData.isBG,
'is-duet': lyricData.isDuet
}
]
}
const getLyricLineStyle = (item: ProcessedLyricItem, index: number) => {
if (item.type !== 'lyric') return {}
// 如果用户正在滚动,或者模糊设置关闭,则不模糊
if (!settings.lyricsBlur.value || userScrolling.value) return { filter: 'blur(0)' }
const activeIdx = firstActiveIndex.value
const isOn = isLineActive(index)
return {
filter: isOn ? 'blur(0)' : `blur(${Math.min(Math.abs(activeIdx - index) * 1.8, 10)}px)`
}
}
const jumpSeek = (time: number) => {
if (!time) return
userScrolling.value = false
if (userScrollTimeoutId !== null) {
clearTimeout(userScrollTimeoutId)
userScrollTimeoutId = null
}
const offsetMs = settings.lyricOffset.value
const targetTime = (time - offsetMs) / 1000
audioPlayerControl.seekAndPlay(targetTime)
}
// Utils
const getFontSize = (size: number, mode: string) => {
if (mode === 'adaptive') {
return `calc(${size} / 1080 * 100vh)`
}
return `${size}px`
}
const getLyricLanguage = (lyric: string): 'ja' | 'ko' | 'zh-CN' | 'en' => {
if (!lyric || typeof lyric !== 'string') return 'en'
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(lyric)) return 'ja'
if (/[\uAC00-\uD7AF]/.test(lyric)) return 'ko'
if (/[\u4E00-\u9FFF]/.test(lyric)) return 'zh-CN'
return 'en'
}
// Watchers
watch(firstActiveIndex, (val, oldVal) => {
lyricsScroll(val)
if (typeof oldVal === 'number' && oldVal >= 0 && oldVal !== val) {
yrcFadingLineIndex.value = oldVal
yrcFadingUntilAt.value = Date.now() + YRC_LINE_FADE_MS
}
})
onMounted(() => {
nextTick().then(() => {
lyricsScroll(firstActiveIndex.value)
})
})
onBeforeUnmount(() => {
if (scrollAnimationId !== null) {
cancelAnimationFrame(scrollAnimationId)
scrollAnimationId = null
}
if (userScrollTimeoutId !== null) {
clearTimeout(userScrollTimeoutId)
userScrollTimeoutId = null
}
})
</script>
<style lang="scss" scoped>
.lyric {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-start;
height: 100%;
overflow: hidden;
.lyric-scroll-container {
width: 100%;
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding-left: var(--lrc-left-padding, 10px);
padding-right: 80px;
box-sizing: border-box;
/* 隐藏滚动条 */
scrollbar-width: none;
-ms-overflow-style: none;
&::-webkit-scrollbar {
display: none;
}
@media (max-width: 990px) {
padding-right: 20px;
}
}
.placeholder {
width: 100%;
&:first-child {
height: 300px;
display: flex;
align-items: flex-end;
}
&:last-child {
height: 0;
padding-top: 100%;
}
}
.lyric-content {
width: 100%;
height: 100%;
}
/* 倒计时行 */
.countdown-line {
margin: 6px 0;
padding: 10px 16px;
display: flex;
align-items: center;
min-height: 48px;
.count-down {
display: flex;
flex-direction: row-reverse;
align-items: center;
animation: breathe 4s ease-in-out infinite;
.point {
width: 28px;
height: 28px;
margin-right: 12px;
border-radius: 50%;
background-color: rgb(var(--main-cover-color));
transition: opacity 0.5s;
@media (max-width: 900px) {
width: 24px;
height: 24px;
}
@media (max-width: 700px) {
width: 20px;
height: 20px;
}
}
}
}
.lrc-line {
position: relative;
display: flex;
flex-direction: column;
margin: 6px 0;
padding: 10px 16px;
transform: scale(0.95);
transform-origin: left center;
will-change: filter, opacity, transform;
transition:
filter 0.35s,
opacity 0.35s,
transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
cursor: pointer;
width: 100%;
.content {
display: block;
font-size: var(--lrc-size);
font-weight: var(--lrc-bold);
width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
hyphens: auto;
.content-text {
position: relative;
display: inline-block;
overflow: visible;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
.yrc-word {
display: inline-block;
box-sizing: border-box;
padding-block: 0.2em;
margin-block: -0.2em;
opacity: var(--yrc-opacity, 0.3);
}
&.end-with-space {
margin-right: 12px;
&:last-child {
margin-right: 0;
}
}
}
}
.tran {
margin-top: 8px;
opacity: 0.6;
font-size: var(--lrc-tran-size);
transition: opacity 0.35s;
width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
hyphens: auto;
}
.roma {
margin-top: 4px;
opacity: 0.5;
font-size: var(--lrc-roma-size);
transition: opacity 0.35s;
width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
hyphens: auto;
}
&.is-lrc {
opacity: 0.3;
}
&.is-yrc {
.content {
display: flex;
flex-wrap: wrap;
width: 100%;
overflow-wrap: anywhere;
word-break: break-word;
white-space: normal;
}
.tran,
.roma {
opacity: 0.3;
}
&.is-bg {
opacity: 0.4;
transform: scale(0.7);
padding: 0px 20px;
}
&.is-duet {
transform-origin: right;
.content,
.tran,
.roma {
text-align: right;
justify-content: flex-end;
}
}
}
&.on {
opacity: 1 !important;
transform: scale(1);
.tran,
.roma {
opacity: 0.6;
}
&.is-bg {
opacity: 0.85 !important;
}
}
&::before {
content: '';
display: block;
position: absolute;
left: 0px;
top: 0;
width: 100%;
height: 100%;
border-radius: 8px;
background-color: rgba(var(--main-cover-color), 0.14);
opacity: 0;
z-index: 0;
transform: scale(1.05);
transition:
transform 0.35s ease,
opacity 0.35s ease;
pointer-events: none;
}
}
@media (hover: hover) and (pointer: fine) {
.lrc-line:hover {
opacity: 1;
&::before {
transform: scale(1);
opacity: 1;
}
}
.lrc-line:active {
&::before {
transform: scale(0.95);
}
}
}
&.flex-end,
&.align-right {
span {
text-align: right;
}
.placeholder {
justify-content: flex-end;
}
.lrc-line {
transform-origin: right;
.content {
text-align: right;
}
&.is-duet {
transform-origin: left;
.content,
.tran,
.roma {
text-align: left;
justify-content: flex-start;
}
}
}
.countdown-line {
justify-content: flex-end;
.count-down {
flex-direction: row;
}
}
}
&.center,
&.pure {
span {
text-align: center !important;
}
.placeholder {
justify-content: center !important;
}
.lrc-line {
transform-origin: center !important;
.content {
text-align: center !important;
justify-content: center !important;
}
}
.countdown-line {
justify-content: center;
}
}
/* 逐字歌词:激活行启用 mask 动画 */
.lrc-line.is-yrc.on {
.content-text {
.yrc-word {
will-change: -webkit-mask-position-x;
mask-image: linear-gradient(
to right,
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
);
mask-size: 220% 100%;
mask-repeat: no-repeat;
-webkit-mask-image: linear-gradient(
to right,
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
);
-webkit-mask-size: 220% 100%;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position-x: var(--yrc-mask-x, 0%);
transition: none;
}
}
}
}
.lyric-loading {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
}
</style>
+174
View File
@@ -0,0 +1,174 @@
<template>
<div class="site-footer">
<div class="footer-info">
<span v-if="icpNumber" class="footer-item">
<a
:href="`https://beian.miit.gov.cn/`"
class="icp-link"
rel="noopener noreferrer"
target="_blank"
>
{{ icpNumber }}
</a>
</span>
<span v-if="gonganNumber" class="footer-item">
<a
:href="gonganLink || 'https://beian.mps.gov.cn/'"
class="icp-link"
rel="noopener noreferrer"
target="_blank"
>
{{ gonganNumber }}
</a>
</span>
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
<span v-else class="footer-item">© {{ currentYear }} {{ copyrightOwner }}</span>
<span class="footer-item">Worker in {{ responseTime }}ms</span>
<span class="footer-item">
<NuxtLink class="voicehub-link" to="/changelog">
{{ displayName }} v{{ systemVersion }}
</NuxtLink>
</span>
<span v-if="isNetlify" class="footer-item">
<a
href="https://www.netlify.com"
target="_blank"
rel="noopener noreferrer"
class="netlify-badge"
>
<img
src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg"
alt="Deploys by Netlify"
>
</a>
</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { getCopyrightOwner, getSystemName } from '@/utils/core/security'
import packageJson from '~~/package.json'
// 使用 useSiteConfig composable 获取配置
const { siteTitle, siteVersion, icp: icpNumber, gonganNumber } = useSiteConfig()
const config = useRuntimeConfig()
// 自动生成公安联网备案链接
const gonganLink = computed(() => {
if (!gonganNumber.value) return '#'
// 提取数字部分
const codeMatch = gonganNumber.value.match(/\d+/)
const code = codeMatch ? codeMatch[0] : ''
if (!code) return '#'
return `https://beian.mps.gov.cn/#/query/webSearch?code=${code}`
})
const isNetlify = config.public.isNetlify
const copyrightOwner = getCopyrightOwner()
const systemName = getSystemName()
const systemVersion = computed(() => siteVersion.value || packageJson.version)
const displayName = computed(() => siteTitle.value || systemName)
const currentYear = new Date().getFullYear()
const responseTime = ref(0)
onMounted(() => {
if (typeof window !== 'undefined' && window.performance) {
// 使用 requestAnimationFrame 确保在渲染后计算
requestAnimationFrame(() => {
const loadTime = Math.round(window.performance.now())
responseTime.value = loadTime
})
}
})
</script>
<style scoped>
.site-footer {
text-align: center;
padding: 20px 0;
margin-top: 30px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
width: 100%;
}
.footer-info {
display: flex;
justify-content: center;
align-items: center;
flex-wrap: wrap;
gap: 0;
}
.footer-item {
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
white-space: nowrap;
}
.footer-item:not(:last-child)::after {
content: ' | ';
margin: 0 10px;
color: rgba(255, 255, 255, 0.4);
}
.footer-item a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
.footer-item a:hover {
color: rgba(255, 255, 255, 0.8);
}
.icp-link,
.voicehub-link {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
.icp-link:hover,
.voicehub-link:hover {
color: rgba(255, 255, 255, 0.8);
}
.netlify-badge {
display: inline-flex;
align-items: center;
vertical-align: middle;
}
.netlify-badge img {
height: 16px;
opacity: 0.8;
transition: opacity 0.2s ease;
}
.netlify-badge:hover img {
opacity: 1;
}
@media (max-width: 768px) {
.site-footer {
padding: 15px 0;
margin-top: 20px;
}
.footer-info {
gap: 0;
}
.footer-item {
font-size: 11px;
}
.footer-item:not(:last-child)::after {
margin: 0 2px;
}
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="show"
class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
@click="$emit('close')"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl flex flex-col"
@click.stop
>
<!-- 头部 -->
<div class="p-8 pb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500"
>
<Icon name="alert-triangle" :size="20" />
</div>
歌曲已存在
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">这首歌曲已经在列表中了,不能重复投稿</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
@click="$emit('close')"
>
<Icon name="x" :size="20" />
</button>
</div>
<div class="p-8 pt-4 space-y-6">
<p class="text-sm text-zinc-400 leading-relaxed font-medium">
您可以为它点赞支持,或者尝试搜索其他歌曲!
</p>
<!-- 歌曲卡片 -->
<div
class="flex gap-4 p-5 bg-zinc-950 border border-zinc-800 rounded-3xl group shadow-xl"
>
<div
class="w-20 h-20 rounded-2xl overflow-hidden bg-zinc-900 flex-shrink-0 border border-zinc-800"
>
<img
v-if="song.cover"
:alt="song.title"
:src="convertToHttps(song.cover)"
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
<Icon name="music" :size="32" />
</div>
</div>
<div class="flex-1 min-w-0 py-1">
<h4 class="text-base font-black text-zinc-100 truncate mb-1">{{ song.title }}</h4>
<p
class="text-[10px] font-black uppercase tracking-widest text-zinc-500 truncate mb-3"
>
{{ song.artist }}
</p>
<div class="flex items-center gap-4">
<div
class="flex items-center gap-1.5 text-[10px] font-black text-red-500 uppercase tracking-widest"
>
<Icon name="heart" :size="12" class="fill-current" />
{{ song.voteCount || 0 }} 票
</div>
<div
:class="[
'px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest border',
song.played
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
]"
>
{{ song.played ? '已播放' : '待播放' }}
</div>
</div>
<p class="text-[10px] text-zinc-600 font-black uppercase tracking-widest mt-2">
投稿者:{{ song.requester }}
</p>
</div>
</div>
</div>
<!-- 底部按钮 -->
<div class="p-8 pt-0 flex gap-3">
<button
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest active:scale-95"
@click="$emit('close')"
>
返回重选
</button>
<button
:disabled="liking || song.voted"
class="flex-[2] px-6 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50"
@click="handleLike"
>
<Icon v-if="liking" name="loader" :size="16" class="animate-spin" />
<Icon v-else name="heart" :size="16" :class="[song.voted ? 'fill-current' : '']" />
{{ song.voted ? '已点赞' : '立即点赞' }}
</button>
</div>
</div>
</div>
</Transition>
</template>
<script lang="ts" setup>
import { ref } from 'vue'
import type { Song } from '~~/types'
import { convertToHttps } from '~/utils/url'
import Icon from '~/components/UI/Icon.vue'
interface Props {
show: boolean
song: Song
}
const props = defineProps<Props>()
const emit = defineEmits<{
close: []
like: [songId: number]
}>()
const liking = ref(false)
const handleOverlayClick = () => {
emit('close')
}
const handleImageError = (event: Event) => {
const target = event.target as HTMLImageElement
target.style.display = 'none'
}
const handleLike = async () => {
if (liking.value || props.song.voted) return
liking.value = true
try {
emit('like', props.song.id)
} finally {
liking.value = false
}
}
</script>
<style scoped></style>
+484
View File
@@ -0,0 +1,484 @@
<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-[2000] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
@click.self="close"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
>
<!-- 结果展示视图 -->
<div
v-if="importResult"
class="flex flex-col flex-1 overflow-hidden animate-in fade-in duration-500"
>
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500"
>
<Icon name="success" :size="20" />
</div>
导入结果
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">歌曲导入操作已完成</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<div class="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
<div class="grid grid-cols-2 gap-4">
<div
class="p-6 bg-emerald-500/5 border border-emerald-500/20 rounded-3xl flex flex-col items-center gap-2"
>
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
>成功导入</span
>
<span class="text-4xl font-black text-emerald-400">{{
importResult.success
}}</span>
</div>
<div
class="p-6 bg-red-500/5 border border-red-500/20 rounded-3xl flex flex-col items-center gap-2"
>
<span class="text-[10px] font-black text-red-500 uppercase tracking-widest"
>失败/跳过</span
>
<span class="text-4xl font-black text-red-400">{{ importResult.failed }}</span>
</div>
</div>
<div v-if="importResult.details && importResult.details.length > 0" class="space-y-4">
<div
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>
<Icon name="info" :size="12" />
详细处理记录
</div>
<div class="space-y-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
<div
v-for="(detail, index) in importResult.details"
:key="index"
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-xs text-zinc-400 font-medium"
>
{{ detail }}
</div>
</div>
</div>
<div
v-else
class="flex flex-col items-center justify-center py-10 text-emerald-500/50"
>
<Icon name="success" :size="48" class="mb-4" />
<p class="text-sm font-bold uppercase tracking-widest">全部歌曲导入成功!</p>
</div>
</div>
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50">
<button
class="w-full px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg active:scale-95"
@click="close"
>
完成并返回
</button>
</div>
</div>
<!-- 正常导入视图 -->
<div v-else class="flex flex-col flex-1 overflow-hidden">
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
>
<Icon name="download" :size="20" />
</div>
从历史学期导入
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">选择以往学期的歌曲并导入到当前学期</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<div class="p-8 py-6 space-y-6 border-b border-zinc-800/50">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>选择学期</label
>
<CustomSelect
v-model="selectedSemester"
:options="semesterList"
label-key="name"
value-key="name"
placeholder="请选择目标学期"
class="w-full"
/>
</div>
<div class="flex bg-zinc-950 p-1 rounded-xl border border-zinc-800">
<button
v-for="type in ['unplayed', 'played', 'all']"
:key="type"
type="button"
:class="[
'flex-1 px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all',
filterType === type
? 'bg-blue-600 text-white shadow-lg'
: 'text-zinc-500 hover:text-zinc-300'
]"
@click="filterType = type"
>
{{ getFilterLabel(type) }}
</button>
</div>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar bg-zinc-950/30">
<div
v-if="loadingSemesters || loadingSongs"
class="flex flex-col items-center justify-center py-20 text-zinc-500"
>
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-blue-500" />
<div class="text-[10px] font-black uppercase tracking-widest">正在加载歌曲...</div>
</div>
<div
v-else-if="error"
class="flex flex-col items-center justify-center py-20 text-center px-8"
>
<div
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
>
<Icon name="alert-triangle" :size="32" />
</div>
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
<button
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="loadSongs"
>
重试加载
</button>
</div>
<div
v-else-if="!selectedSemester"
class="flex flex-col items-center justify-center py-20 text-zinc-600"
>
<Icon name="list" :size="48" class="mb-4 opacity-20" />
<p class="text-sm font-bold uppercase tracking-widest">请先选择一个学期</p>
</div>
<div
v-else-if="filteredSongs.length === 0"
class="flex flex-col items-center justify-center py-20 text-zinc-600"
>
<Icon name="search" :size="48" class="mb-4 opacity-20" />
<p class="text-sm font-bold uppercase tracking-widest">没有找到符合条件的歌曲</p>
</div>
<div v-else class="grid grid-cols-1 gap-2">
<div
v-for="song in filteredSongs"
:key="song.id"
class="group flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all border"
:class="[
isSelected(song.id)
? 'bg-blue-600/10 border-blue-500/30 shadow-lg'
: 'bg-zinc-900 border-transparent hover:border-zinc-800'
]"
@click="toggleSelection(song.id)"
>
<div
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all"
:class="[
isSelected(song.id)
? 'bg-blue-600 border-blue-600 text-white'
: 'border-zinc-800 text-transparent'
]"
>
<Icon name="check" :size="14" />
</div>
<div
class="w-12 h-12 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0 shadow-md"
>
<img
:src="convertToHttps(song.cover)"
alt="cover"
class="w-full h-full object-cover"
loading="lazy"
>
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-black text-zinc-100 truncate mb-0.5">
{{ song.title }}
</h4>
<div class="flex items-center gap-2">
<span class="text-[10px] text-zinc-500 truncate">{{ song.artist }}</span>
<span class="text-[10px] text-zinc-700 font-bold">•</span>
<span class="text-[10px] text-zinc-600 truncate">{{ song.requester }}</span>
</div>
</div>
<div
:class="[
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter border',
song.played
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
]"
>
{{ song.played ? '已收录' : '未收录' }}
</div>
</div>
</div>
</div>
<div
class="p-8 border-t border-zinc-800/50 bg-zinc-900/50 flex flex-col md:flex-row gap-4 items-center justify-between"
>
<div class="flex items-center gap-4 w-full md:w-auto">
<button
type="button"
class="text-[10px] font-black text-blue-500 hover:text-blue-400 uppercase tracking-widest transition-colors"
@click="toggleSelectAll"
>
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
</button>
<div class="h-4 w-px bg-zinc-800 hidden md:block" />
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">
已选择 <span class="text-blue-500">{{ selectedSongIds.size }}</span> 首歌曲
</span>
</div>
<div class="flex gap-3 w-full md:w-auto">
<button
type="button"
class="flex-1 md:flex-none px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="close"
>
取消
</button>
<button
type="button"
class="flex-[2] md:flex-none px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-blue-900/20 active:scale-95"
:disabled="selectedSongIds.size === 0 || importing"
@click="handleImport"
>
<Icon v-if="importing" name="refresh" :size="16" class="animate-spin" />
<Icon v-else name="download" :size="16" />
{{ importing ? '正在导入...' : '确认导入' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { convertToHttps } from '~/utils/url'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import Icon from '~/components/UI/Icon.vue'
const props = defineProps({
show: Boolean
})
const emit = defineEmits(['close', 'import-success'])
const semesterList = ref([])
const selectedSemester = ref('')
const songs = ref([])
const loadingSemesters = ref(false)
const loadingSongs = ref(false)
const error = ref('')
const filterType = ref('unplayed') // '未收录', '已收录', '全部'
const selectedSongIds = ref(new Set())
const importing = ref(false)
const importResult = ref(null)
const getFilterLabel = (type) => {
const map = {
unplayed: '未被收录',
played: '被收录',
all: '全部'
}
return map[type]
}
const filteredSongs = computed(() => {
if (!songs.value) return []
return songs.value.filter((song) => {
if (filterType.value === 'all') return true
if (filterType.value === 'played') return song.played
if (filterType.value === 'unplayed') return !song.played
return true
})
})
const isAllSelected = computed(() => {
return (
filteredSongs.value.length > 0 &&
filteredSongs.value.every((s) => selectedSongIds.value.has(s.id))
)
})
const isSelected = (id) => selectedSongIds.value.has(id)
const toggleSelection = (id) => {
if (selectedSongIds.value.has(id)) {
selectedSongIds.value.delete(id)
} else {
selectedSongIds.value.add(id)
}
}
const toggleSelectAll = () => {
if (isAllSelected.value) {
// 取消全选可见项
filteredSongs.value.forEach((s) => selectedSongIds.value.delete(s.id))
} else {
// 全选可见项
filteredSongs.value.forEach((s) => selectedSongIds.value.add(s.id))
}
}
const fetchSemesters = async () => {
loadingSemesters.value = true
try {
const res = await $fetch('/api/semesters/options')
if (res.success) {
// 彻底过滤掉当前活跃学期,只显示往期学期供导入
semesterList.value = res.data.filter(
(sem) => sem.isActive === false || sem.isActive === 0 || sem.isActive === '0'
)
// 不自动选择,强制用户手动选择
}
} catch (e) {
console.error('获取学期列表失败', e)
error.value = '加载学期列表失败'
} finally {
loadingSemesters.value = false
}
}
const loadSongs = async () => {
if (!selectedSemester.value) return
loadingSongs.value = true
error.value = ''
songs.value = []
selectedSongIds.value.clear()
try {
const res = await $fetch('/api/songs', {
params: {
semester: selectedSemester.value,
scope: 'mine',
bypass_cache: 'true' // 确保获取最新数据
}
})
if (res.success) {
songs.value = res.data.songs
}
} catch (e) {
console.error('获取歌曲失败', e)
error.value = '加载歌曲列表失败'
} finally {
loadingSongs.value = false
}
}
const handleImport = async () => {
if (selectedSongIds.value.size === 0) return
importing.value = true
try {
const res = await $fetch('/api/songs/import', {
method: 'POST',
body: {
songIds: Array.from(selectedSongIds.value)
}
})
if (res.success) {
if (res.results) {
importResult.value = res.results
} else {
// 如果结果结构不同,进行降级处理
importResult.value = {
success: res.count,
failed: 0,
details: []
}
}
emit('import-success')
// 不关闭弹窗,显示结果视图
}
} catch (e) {
console.error('导入失败', e)
if (window.$showNotification) {
window.$showNotification(e.message || '导入失败', 'error')
}
} finally {
importing.value = false
}
}
const close = () => {
emit('close')
importResult.value = null
}
watch(
() => props.show,
(newVal) => {
if (newVal) {
importResult.value = null // 重置结果
if (semesterList.value.length === 0) {
fetchSemesters()
}
// 重置状态
selectedSongIds.value.clear()
filterType.value = 'unplayed'
}
}
)
watch(selectedSemester, () => {
if (selectedSemester.value) {
loadSongs()
}
})
</script>
<style scoped></style>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
<template>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
>
<div class="bg-zinc-950 border border-zinc-800 rounded-2xl w-full max-w-2xl overflow-hidden" @click.stop>
<div class="px-5 py-4 border-b border-zinc-800 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-black text-zinc-100">歌曲评论</p>
<p class="text-[10px] text-zinc-500 mt-1 truncate">
{{ song?.title }} - {{ song?.artist }}
</p>
</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="emit('close')"
>
<X :size="16" class="mx-auto" />
</button>
</div>
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
<div v-if="loading" class="text-center text-zinc-500 py-6">加载中...</div>
<div v-else-if="comments.length === 0" class="text-center text-zinc-500 py-6">
暂无评论
</div>
<div v-else class="space-y-3">
<div
v-for="c in comments"
:key="c.id"
class="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-black text-zinc-200 truncate">{{ c.authorName || '用户' }}</p>
<p class="text-[10px] text-zinc-500 mt-0.5">{{ formatTime(c.createdAt) }}</p>
</div>
<button
v-if="canDelete(c)"
class="px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
:disabled="deletingId === c.id"
@click="deleteComment(c)"
>
删除
</button>
</div>
<p class="text-sm text-zinc-200 mt-3 whitespace-pre-wrap leading-relaxed">{{ c.content }}</p>
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-zinc-800 space-y-3">
<div v-if="!isAuthenticated" class="text-xs text-zinc-500">
登录后可发表评论
</div>
<div v-else class="space-y-2">
<textarea
v-model="draft"
:rows="3"
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 resize-none"
placeholder="写下你的评论(最多 300 字)"
/>
<div class="flex items-center justify-between gap-3">
<p class="text-[10px] text-zinc-500">{{ draft.length }} / 300</p>
<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="posting || !draft.trim() || draft.trim().length > 300"
@click="postComment"
>
{{ posting ? '发送中...' : '发送' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, watch, ref } from 'vue'
import { X } from '@lucide/vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
const props = defineProps({
show: { type: Boolean, default: false },
song: { type: Object, default: null }
})
const emit = defineEmits(['close'])
const { user, isAuthenticated, getAuthConfig } = useAuth()
const { showToast } = useToast()
const comments = ref([])
const loading = ref(false)
const posting = ref(false)
const deletingId = ref(null)
const draft = ref('')
const songId = computed(() => props.song?.id)
const formatTime = (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')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
}
const load = async () => {
if (!props.show || !songId.value) return
if (!isAuthenticated.value) {
comments.value = []
return
}
try {
loading.value = true
const authConfig = getAuthConfig()
const data = await $fetch(`/api/songs/${songId.value}/comments`, { ...authConfig, query: { t: Date.now() } })
comments.value = Array.isArray(data) ? data : []
} catch {
showToast('加载评论失败', 'error')
} finally {
loading.value = false
}
}
watch(
() => props.show,
(v) => {
if (v) {
draft.value = ''
load()
}
}
)
const canDelete = (c) => {
const me = user.value
if (!me) return false
return c.userId === me.id || ['ADMIN', 'SUPER_ADMIN', 'SONG_ADMIN'].includes(me.role)
}
const postComment = async () => {
if (!songId.value) return
const text = draft.value.trim()
if (!text) return
if (text.length > 300) return
try {
posting.value = true
const authConfig = getAuthConfig()
await $fetch(`/api/songs/${songId.value}/comments`, {
method: 'POST',
...authConfig,
body: { content: text }
})
draft.value = ''
await load()
} catch {
showToast('发送失败', 'error')
} finally {
posting.value = false
}
}
const deleteComment = async (c) => {
if (!songId.value) return
try {
deletingId.value = c.id
const authConfig = getAuthConfig()
await $fetch(`/api/songs/${songId.value}/comments/${c.id}`, {
method: 'DELETE',
...authConfig
})
await load()
} catch {
showToast('删除失败', 'error')
} finally {
deletingId.value = null
}
}
</script>
File diff suppressed because it is too large Load Diff
+282
View File
@@ -0,0 +1,282 @@
<template>
<div :style="{ height }" class="apple-music-lyrics">
<!-- 使用自定义的Apple Music风格歌词显示 -->
<div v-if="!isLoading && !error && currentLyrics.length" class="lyrics-wrapper">
<div ref="lyricContainer" class="custom-lyric-player">
<div
v-for="(line, index) in currentLyrics"
:key="index"
:class="[
'lyric-line',
{
active: index === currentLyricIndex,
passed: index < currentLyricIndex,
upcoming: index > currentLyricIndex
}
]"
:style="getLyricLineStyle(index)"
@click="handleLineClick(line, index)"
>
<div class="main-lyric">{{ line.content || '' }}</div>
<div
v-if="
showTranslation &&
translationLyrics[index]?.content &&
translationLyrics[index].content !== '//'
"
class="translation-lyric"
>
{{ translationLyrics[index].content }}
</div>
</div>
</div>
</div>
<div v-else-if="isLoading" class="placeholder">
<div class="loading-spinner" />
<p>歌词加载中...</p>
</div>
<div v-else-if="error" class="placeholder error">
<p>{{ error }}</p>
</div>
<div v-else class="placeholder">
<p>暂无歌词</p>
</div>
</div>
</template>
<script setup>
import { nextTick, ref, watch } from 'vue'
const props = defineProps({
// 歌词数据
currentLyrics: { type: Array, default: () => [] },
translationLyrics: { type: Array, default: () => [] },
wordByWordLyrics: { type: Array, default: () => [] },
// 播放状态
currentTime: { type: Number, default: 0 },
currentLyricIndex: { type: Number, default: -1 },
isLoading: { type: Boolean, default: false },
error: { type: String, default: '' },
// 样式配置
height: { type: String, default: '400px' },
fontSize: { type: Number, default: 18 },
lineHeight: { type: Number, default: 1.6 },
activeLineColor: { type: String, default: '#ffffff' },
inactiveLineColor: { type: String, default: 'rgba(255, 255, 255, 0.6)' },
backgroundColor: { type: String, default: 'transparent' },
// 交互配置
allowSeek: { type: Boolean, default: true },
// 显示选项
showTranslation: { type: Boolean, default: false }
})
const emit = defineEmits(['seek'])
const lyricContainer = ref(null)
// 处理歌词行点击
const handleLineClick = (line, index) => {
if (props.allowSeek && line.time !== undefined) {
emit('seek', line.time / 1000) // 转换为秒
}
}
// 获取歌词行样式
const getLyricLineStyle = (index) => {
const isActive = index === props.currentLyricIndex
const isPassed = index < props.currentLyricIndex
return {
fontSize: `${props.fontSize}px`,
lineHeight: props.lineHeight,
color: isActive ? props.activeLineColor : props.inactiveLineColor,
opacity: isPassed ? 0.4 : isActive ? 1 : 0.7,
transform: isActive ? 'scale(1.05)' : 'scale(1)',
fontWeight: isActive ? '600' : '400'
}
}
// 自动滚动到当前歌词
watch(
() => props.currentLyricIndex,
(newIndex) => {
if (newIndex >= 0 && lyricContainer.value) {
nextTick(() => {
const container = lyricContainer.value
const activeLine = container.querySelector('.lyric-line.active')
if (activeLine) {
const containerHeight = container.clientHeight
const lineTop = activeLine.offsetTop
const lineHeight = activeLine.clientHeight
// 计算滚动位置,让当前歌词居中显示
const scrollTop = lineTop - containerHeight / 2 + lineHeight / 2
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}
})
}
}
)
</script>
<style scoped>
.apple-music-lyrics {
width: 100%;
overflow: hidden;
background: transparent;
position: relative;
}
.lyrics-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.custom-lyric-player {
width: 100%;
height: 100%;
overflow-y: auto;
padding: 60px 20px;
box-sizing: border-box;
/* 隐藏滚动条但保持滚动功能 */
scrollbar-width: none;
-ms-overflow-style: none;
}
.custom-lyric-player::-webkit-scrollbar {
display: none;
}
.lyric-line {
text-align: center;
margin: 16px 0;
padding: 8px 16px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border-radius: 8px;
user-select: none;
position: relative;
}
.lyric-line:hover {
background: rgba(255, 255, 255, 0.05);
}
.lyric-line.active {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
}
.main-lyric {
font-size: inherit;
line-height: inherit;
font-weight: inherit;
margin-bottom: 4px;
transition: all 0.3s ease;
}
.translation-lyric {
font-size: 0.85em;
opacity: 0.8;
font-weight: 400;
color: rgba(255, 255, 255, 0.7);
margin-top: 4px;
}
.lyric-line.active .translation-lyric {
color: rgba(255, 255, 255, 0.9);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: rgba(255, 255, 255, 0.6);
font-size: 16px;
}
.placeholder.error {
color: #ff6b6b;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top: 2px solid rgba(255, 255, 255, 0.6);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 12px;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
/* Apple Music风格的渐变效果 */
.custom-lyric-player::before,
.custom-lyric-player::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 60px;
pointer-events: none;
z-index: 1;
}
.custom-lyric-player::before {
top: 0;
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent);
}
.custom-lyric-player::after {
bottom: 0;
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
}
/* 深色主题适配 */
@media (prefers-color-scheme: dark) {
.placeholder {
color: rgba(255, 255, 255, 0.7);
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.custom-lyric-player {
padding: 40px 16px;
}
.lyric-line {
margin: 12px 0;
padding: 6px 12px;
}
.placeholder {
font-size: 14px;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,73 @@
<template>
<audio
v-if="song?.musicUrl"
ref="audioPlayer"
:src="song.musicUrl"
crossorigin="anonymous"
autoplay
preload="auto"
referrerpolicy="no-referrer"
@canplay="$emit('canplay', $event)"
@ended="$emit('ended', $event)"
@error="$emit('error', $event)"
@loadedmetadata="$emit('loadedmetadata', $event)"
@loadstart="$emit('loadstart', $event)"
@pause="$emit('pause', $event)"
@play="$emit('play', $event)"
@timeupdate="$emit('timeupdate', $event)"
/>
<!-- 如果没有 musicUrl(如哔哩哔哩视频),创建一个虚拟的 audio 元素 -->
<audio v-else-if="song" ref="audioPlayer" style="display: none" />
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
const props = defineProps({
song: {
type: Object,
default: null
}
})
const emit = defineEmits([
'timeupdate',
'ended',
'loadedmetadata',
'error',
'play',
'pause',
'loadstart',
'canplay'
])
const audioPlayer = ref(null)
// 错误消息常量,避免使用魔法字符串
const ERROR_MESSAGES = {
BILIBILI_NO_AUDIO: 'No audio URL available for Bilibili video'
}
// 监听 song 变化,如果没有 musicUrl 则立即触发错误
watch(
() => props.song,
(newSong, oldSong) => {
if (newSong && !newSong.musicUrl) {
// 使用 nextTick 确保 DOM 已更新
nextTick(() => {
// 创建一个自定义错误对象
const error = new Error(ERROR_MESSAGES.BILIBILI_NO_AUDIO)
error.name = 'BilibiliNoAudioError'
// 立即触发错误事件
emit('error', error)
})
}
},
{ immediate: true }
)
defineExpose({
audioPlayer
})
</script>
@@ -0,0 +1,331 @@
<template>
<div class="player-controls">
<div class="progress-container">
<div class="control-with-progress">
<!-- 上一首按钮 -->
<button
:disabled="!hasPrevious"
:title="hasPrevious ? '上一首' : '没有上一首'"
class="control-btn prev-btn"
@click="$emit('previous')"
>
<Icon :size="24" color="white" name="skip-back" />
</button>
<!-- 播放/暂停按钮 -->
<button
:disabled="hasError || isLoadingTrack"
class="control-btn play-pause-btn"
@click="$emit('togglePlay')"
>
<div v-if="isLoadingTrack" class="loading-spinner" />
<Icon v-else-if="isPlaying" :size="18" color="white" name="pause" />
<Icon v-else :size="18" color="white" name="play" />
</button>
<!-- 下一首按钮 -->
<button
:disabled="!hasNext"
:title="hasNext ? '下一首' : '没有下一首'"
class="control-btn next-btn"
@click="$emit('next')"
>
<Icon :size="24" color="white" name="skip-forward" />
</button>
<div class="progress-container-wrapper">
<div
ref="progressBar"
class="progress-bar"
@click="handleProgressClick"
@mousedown="handleProgressMouseDown"
@touchstart="handleProgressTouchStart"
>
<div
:class="{ dragging: isDragging }"
:style="{ '--progress-width': `${progress}%` }"
class="progress"
>
<div :class="{ dragging: isDragging }" class="progress-thumb" />
</div>
</div>
</div>
</div>
<div class="time-display">
<span class="current-time">{{ formatTime(currentTime) }}</span>
<span class="duration">{{ formatTime(duration) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import Icon from '~/components/UI/Icon.vue'
const props = defineProps({
isPlaying: {
type: Boolean,
default: false
},
hasError: {
type: Boolean,
default: false
},
hasPrevious: {
type: Boolean,
default: false
},
hasNext: {
type: Boolean,
default: false
},
progress: {
type: Number,
default: 0
},
isDragging: {
type: Boolean,
default: false
},
currentTime: {
type: Number,
default: 0
},
duration: {
type: Number,
default: 0
},
isLoading: {
type: Boolean,
default: false
},
isLoadingTrack: {
type: Boolean,
default: false
}
})
const emit = defineEmits([
'togglePlay',
'previous',
'next',
'startDrag',
'startTouchDrag',
'seekToPosition'
])
const progressBar = ref(null)
const formatTime = (time) => {
if (!time || isNaN(time)) return '0:00'
const minutes = Math.floor(time / 60)
const seconds = Math.floor(time % 60)
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
// 进度条事件处理
const handleProgressMouseDown = (event) => {
emit('startDrag', event, progressBar.value)
}
const handleProgressTouchStart = (event) => {
emit('startTouchDrag', event, progressBar.value)
}
const handleProgressClick = (event) => {
// 防止拖拽时触发点击
if (props.isDragging) return
emit('seekToPosition', event)
}
defineExpose({
progressBar
})
</script>
<style scoped>
.player-controls {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 0;
}
.progress-container {
width: 100%;
display: flex;
flex-direction: column;
gap: 8px;
}
.control-with-progress {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.control-btn {
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(10px);
flex-shrink: 0;
}
.control-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
transform: scale(1.05);
}
.control-btn:active:not(:disabled) {
transform: scale(0.95);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.play-pause-btn {
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.15);
}
.loading-spinner {
width: 18px;
height: 18px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.progress-container-wrapper {
flex: 1;
min-width: 0;
}
.progress-bar {
width: 100%;
height: 20px; /* 增加触摸区域 */
cursor: pointer;
position: relative;
display: flex;
align-items: center;
touch-action: none; /* 防止移动端滚动 */
}
.progress {
width: 100%;
height: 4px;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
position: relative;
overflow: hidden;
transition: height 0.2s ease;
}
.progress::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: var(--progress-width, 0%);
background: linear-gradient(90deg, #4facfe 0%, #00f2fe 100%);
border-radius: 2px;
transition: width 0.05s linear;
will-change: width;
}
.progress-thumb {
position: absolute;
left: calc(var(--progress-width, 0%) - 6px);
top: 50%;
transform: translateY(-50%);
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
opacity: 0;
transition:
opacity 0.2s ease,
transform 0.1s ease;
will-change: opacity, transform;
pointer-events: none;
}
.progress-bar:hover .progress,
.progress.dragging {
height: 6px;
}
.progress-bar:hover .progress-thumb,
.progress-thumb.dragging {
opacity: 1;
}
.progress-thumb.dragging {
transform: translateY(-50%) scale(1.2);
}
/* 移动端优化 */
@media (hover: none) and (pointer: coarse) {
.progress-bar {
height: 24px;
}
.progress {
height: 6px;
}
.progress-thumb {
width: 16px;
height: 16px;
left: calc(var(--progress-width, 0%) - 8px);
opacity: 1;
}
.progress-bar:active .progress {
height: 8px;
}
}
.time-display {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: rgba(255, 255, 255, 0.8);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
width: 100%;
}
.current-time,
.duration {
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,186 @@
<template>
<div class="player-info">
<div class="cover-container" title="点击打开歌词" @click="openLyrics">
<template v-if="song.cover && !coverError">
<img
:src="convertToHttps(song.cover)"
alt="封面"
class="player-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
</template>
<div v-else class="text-cover">
{{ getFirstChar(song.title || '') }}
</div>
<!-- 悬浮提示层 -->
<div class="lyrics-overlay">
<svg
fill="none"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 5h18v2H3V5zm0 4h14v2H3V9zm0 4h18v2H3v-2zm0 4h10v2H3v-2z"
fill="currentColor"
opacity="0.9"
/>
<circle cx="20" cy="11" fill="currentColor" opacity="0.7" r="2" />
<circle cx="18" cy="15" fill="currentColor" opacity="0.7" r="1.5" />
</svg>
<span class="lyrics-text">歌词</span>
</div>
</div>
<div class="player-text">
<h4>{{ song.title }}</h4>
<p>{{ song.artist }}</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { convertToHttps } from '~/utils/url'
const props = defineProps({
song: {
type: Object,
required: true
}
})
const emit = defineEmits(['openLyrics'])
const coverError = ref(false)
const handleImageError = () => {
coverError.value = true
}
const getFirstChar = (title) => {
if (!title) return '音'
return title.trim().charAt(0)
}
const openLyrics = () => {
emit('openLyrics')
}
</script>
<style scoped>
.player-info {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
flex: 1;
}
.cover-container {
width: 48px;
height: 48px;
aspect-ratio: 1;
border-radius: 8px;
overflow: hidden;
flex-shrink: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
position: relative;
cursor: pointer;
transition: transform 0.2s ease;
}
.cover-container:hover {
transform: scale(1.05);
}
.lyrics-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.3s ease;
color: white;
gap: 2px;
}
.cover-container:hover .lyrics-overlay {
opacity: 1;
}
.lyrics-text {
font-size: 10px;
font-weight: 500;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.5);
}
.player-cover {
width: 100%;
height: 100%;
object-fit: cover;
/* 添加辉光效果 */
box-shadow:
0 0 10px rgba(255, 255, 255, 0.3),
0 0 20px rgba(255, 255, 255, 0.2),
0 0 30px rgba(255, 255, 255, 0.1);
transition: box-shadow 0.3s ease;
}
.player-cover:hover {
box-shadow:
0 0 15px rgba(255, 255, 255, 0.4),
0 0 30px rgba(255, 255, 255, 0.3),
0 0 45px rgba(255, 255, 255, 0.2);
}
.text-cover {
width: 100%;
height: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
font-size: 18px;
font-weight: bold;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.player-text {
min-width: 0;
flex: 1;
}
.player-text h4 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: white;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.player-text p {
margin: 2px 0 0 0;
font-size: 12px;
color: rgba(255, 255, 255, 0.8);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
</style>
+225
View File
@@ -0,0 +1,225 @@
<template>
<div ref="containerRef" class="relative" :class="className">
<div
class="flex items-center gap-2 px-3 py-2 bg-zinc-950 border rounded-lg transition-all cursor-pointer select-none"
:class="[
isOpen
? 'border-blue-500/50 bg-blue-600/5 shadow-lg'
: 'border-zinc-800 hover:border-zinc-700'
]"
@click="toggleDropdown"
>
<div class="flex flex-col flex-1 min-w-0">
<span
v-if="label"
class="text-[8px] font-black uppercase tracking-widest leading-none mb-0.5 transition-colors"
:class="isOpen ? 'text-blue-400' : 'text-zinc-600'"
>
{{ label }}
</span>
<span class="text-[11px] font-bold text-zinc-300 truncate">{{ displayLabel }}</span>
</div>
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
<ChevronDown :size="12" :class="isOpen ? 'text-blue-400' : 'text-zinc-700'" />
</div>
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="isOpen"
ref="dropdownRef"
:style="dropdownStyle"
class="fixed z-[9999] p-1 bg-[#0c0c0e] border border-zinc-800 rounded-lg shadow-2xl backdrop-blur-xl origin-top"
>
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
<button
v-for="option in normalizedOptions"
:key="option.value"
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-[11px] font-bold transition-all"
:class="[
isSelected(option)
? 'bg-blue-600/10 text-blue-400'
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40'
]"
@click="selectOption(option)"
>
<span class="truncate">{{ option.label }}</span>
<Icon v-if="isSelected(option)" name="check" :size="12" class="shrink-0" />
</button>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, computed, nextTick, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
const props = defineProps({
label: String,
modelValue: [String, Number, Object],
value: [String, Number, Object],
options: {
type: Array,
required: true
},
className: String,
labelKey: {
type: String,
default: 'label'
},
valueKey: {
type: String,
default: 'value'
},
placeholder: {
type: String,
default: '请选择'
}
})
const emit = defineEmits(['update:modelValue', 'update:value', 'change'])
const isOpen = ref(false)
const containerRef = ref(null)
const dropdownRef = ref(null)
const dropdownStyle = ref({})
// 统一获取当前值
const currentValue = computed(() => {
if (props.modelValue !== undefined) return props.modelValue
return props.value
})
// 规范化选项为 { label, value } 格式
const normalizedOptions = computed(() => {
return props.options.map((option) => {
if (typeof option === 'object' && option !== null) {
return {
label: option[props.labelKey] || option.label,
value: option[props.valueKey] !== undefined ? option[props.valueKey] : option.value,
original: option
}
}
return { label: option, value: option, original: option }
})
})
// 获取当前显示标签
const displayLabel = computed(() => {
const selected = normalizedOptions.value.find((opt) => opt.value === currentValue.value)
return selected
? selected.label
: currentValue.value && typeof currentValue.value !== 'object'
? currentValue.value
: props.placeholder
})
const isSelected = (option) => {
return option.value === currentValue.value
}
const updatePosition = () => {
if (!isOpen.value || !containerRef.value) return
const rect = containerRef.value.getBoundingClientRect()
// 简单的位置计算,默认向下弹出
// 检查底部空间
const windowHeight = window.innerHeight
const spaceBelow = windowHeight - rect.bottom
const top = rect.bottom + 4
// 如果底部空间不足且顶部空间充足,则向上弹出
if (spaceBelow < 220 && rect.top > 220) {
// 暂时保持向下,或者使用 fixed 定位让它尽量可见
}
dropdownStyle.value = {
top: `${top}px`,
left: `${rect.left}px`,
minWidth: `${Math.max(160, rect.width)}px`
}
}
const toggleDropdown = async () => {
if (isOpen.value) {
isOpen.value = false
} else {
isOpen.value = true
await nextTick()
updatePosition()
}
}
const selectOption = (option) => {
emit('update:modelValue', option.value)
emit('update:value', option.value)
emit('change', option.value)
isOpen.value = false
}
const handleClickOutside = (event) => {
// 检查点击是否在容器内部或者下拉框内部
const isClickInContainer = containerRef.value && containerRef.value.contains(event.target)
const isClickInDropdown = dropdownRef.value && dropdownRef.value.contains(event.target)
if (!isClickInContainer && !isClickInDropdown) {
isOpen.value = false
}
}
const handleScrollOrResize = () => {
if (isOpen.value) {
updatePosition()
}
}
// 监听 isOpen 变化来添加/移除事件监听
watch(isOpen, (val) => {
if (val) {
window.addEventListener('scroll', handleScrollOrResize, true) // capture=true 以捕获子元素的滚动
window.addEventListener('resize', handleScrollOrResize)
} else {
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
}
})
onMounted(() => {
document.addEventListener('mousedown', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('mousedown', handleClickOutside)
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>
+658
View File
@@ -0,0 +1,658 @@
<template>
<div class="space-y-4">
<!-- 表格工具栏 -->
<div
v-if="showToolbar"
class="flex flex-col sm:flex-row items-center justify-between gap-4 px-1"
>
<div class="flex items-center gap-3">
<slot name="toolbar-left">
<div
v-if="selectedRows.length > 0"
class="flex items-center gap-2 px-3 py-1.5 bg-blue-600/10 border border-blue-500/20 rounded-lg"
>
<span class="text-[10px] font-black text-blue-400 uppercase tracking-widest"
>已选择 {{ selectedRows.length }} 项</span
>
<button
class="p-0.5 text-blue-400 hover:text-blue-300 transition-colors"
@click="$emit('clear-selection')"
>
<X :size="12" />
</button>
</div>
</slot>
</div>
<div class="flex items-center gap-2 w-full sm:w-auto">
<slot name="toolbar-right">
<button
v-if="refreshable"
:disabled="loading"
class="flex-1 sm:flex-none flex items-center justify-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all disabled:opacity-50"
@click="$emit('refresh')"
>
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
<span>刷新数据</span>
</button>
</slot>
</div>
</div>
<!-- 表格主容器 -->
<div
class="relative bg-zinc-900/50 border border-zinc-800 rounded-xl overflow-hidden shadow-2xl shadow-black/20"
>
<!-- 加载状态 -->
<div
v-if="loading"
class="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950/60 backdrop-blur-[2px] animate-in fade-in duration-300"
>
<div
class="w-10 h-10 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest">{{
loadingText
}}</span>
</div>
<!-- 桌面端表格 -->
<div class="hidden md:block overflow-x-auto custom-scrollbar">
<table class="w-full text-left border-collapse">
<thead>
<tr class="border-b border-zinc-800 bg-zinc-900/50">
<th v-if="selectable" class="p-4 w-10">
<input
type="checkbox"
:checked="isAllSelected"
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
@change="toggleSelectAll"
>
</th>
<th
v-for="column in columns"
:key="column.key"
:class="[
column.class,
'p-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest'
]"
:style="{ width: column.width }"
>
{{ column.title }}
</th>
<th
v-if="hasActions"
class="p-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right"
>
操作
</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-800/50">
<tr v-if="!loading && data.length === 0">
<td :colspan="totalColumns" class="p-20 text-center">
<slot name="empty">
<div class="flex flex-col items-center gap-3 text-zinc-700">
<Database :size="40" stroke-width="1" />
<span class="text-[10px] font-black uppercase tracking-widest"
>暂无数据内容</span
>
</div>
</slot>
</td>
</tr>
<tr
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="group hover:bg-zinc-800/30 transition-colors cursor-default"
:class="{ 'bg-blue-600/5': selectedRows.includes(getRowKey(row, index)) }"
@click="handleRowClick(row, index)"
>
<td v-if="selectable" class="p-4" @click.stop>
<input
type="checkbox"
:checked="selectedRows.includes(getRowKey(row, index))"
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
@change="toggleSelectRow(getRowKey(row, index))"
>
</td>
<td
v-for="column in columns"
:key="column.key"
:class="[column.class, 'p-4 text-xs font-bold text-zinc-300']"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:index="index"
:value="getNestedValue(row, column.key)"
>
{{ formatCellValue(getNestedValue(row, column.key), column) }}
</slot>
</td>
<td v-if="hasActions" class="p-4 text-right" @click.stop>
<div class="flex items-center justify-end gap-1">
<slot name="actions" :row="row" :index="index">
<button
class="p-2 text-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-xl transition-all"
title="编辑"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-red-400 hover:bg-red-400/10 rounded-xl transition-all"
title="删除"
>
<Trash2 :size="14" />
</button>
</slot>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 移动端卡片列表 -->
<div class="md:hidden divide-y divide-zinc-800">
<div v-if="!loading && data.length === 0" class="p-20 text-center">
<slot name="empty">
<div class="flex flex-col items-center gap-3 text-zinc-700">
<Database :size="40" stroke-width="1" />
<span class="text-[10px] font-black uppercase tracking-widest">暂无数据内容</span>
</div>
</slot>
</div>
<div
v-for="(row, index) in data"
:key="getRowKey(row, index)"
class="p-4 space-y-4 hover:bg-zinc-800/30 transition-colors"
:class="{ 'bg-blue-600/5': selectedRows.includes(getRowKey(row, index)) }"
@click="handleRowClick(row, index)"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div v-if="selectable" @click.stop>
<input
type="checkbox"
:checked="selectedRows.includes(getRowKey(row, index))"
class="w-4 h-4 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all cursor-pointer"
@change="toggleSelectRow(getRowKey(row, index))"
>
</div>
<slot name="mobile-primary" :row="row" :index="index">
<span class="text-sm font-black text-zinc-100">{{
formatCellValue(getNestedValue(row, columns[0]?.key), columns[0])
}}</span>
</slot>
</div>
<div v-if="hasActions" class="flex items-center gap-1" @click.stop>
<slot name="actions" :row="row" :index="index">
<button
class="p-2 text-zinc-500 hover:text-blue-400 hover:bg-blue-400/10 rounded-xl transition-all"
>
<Edit2 :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-red-400 hover:bg-red-400/10 rounded-xl transition-all"
>
<Trash2 :size="14" />
</button>
</slot>
</div>
</div>
<div class="grid grid-cols-2 gap-y-3 gap-x-4">
<div v-for="column in columns.slice(1)" :key="column.key" class="space-y-1">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest block">{{
column.title
}}</span>
<div class="text-xs font-bold text-zinc-400">
<slot
:name="`cell-${column.key}`"
:row="row"
:index="index"
:value="getNestedValue(row, column.key)"
>
{{ formatCellValue(getNestedValue(row, column.key), column) }}
</slot>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import {
X,
RefreshCw,
Database,
Edit2,
Trash2,
ChevronRight,
MoreHorizontal
} from '@lucide/vue'
const props = defineProps({
columns: { type: Array, required: true },
data: { type: Array, required: true },
loading: { type: Boolean, default: false },
loadingText: { type: String, default: '正在加载数据...' },
selectable: { type: Boolean, default: false },
selectedRows: { type: Array, default: () => [] },
rowKey: { type: [String, Function], default: 'id' },
refreshable: { type: Boolean, default: false },
showToolbar: { type: Boolean, default: true },
hasActions: { type: Boolean, default: true },
rowClickable: { type: Boolean, default: false }
})
const emit = defineEmits(['update:selectedRows', 'row-click', 'refresh', 'clear-selection'])
const totalColumns = computed(() => {
let count = props.columns.length
if (props.selectable) count++
if (props.hasActions) count++
return count
})
const isAllSelected = computed(() => {
if (props.data.length === 0) return false
return props.data.every((row) => props.selectedRows.includes(getRowKey(row)))
})
const getRowKey = (row, index) => {
if (typeof props.rowKey === 'function') return props.rowKey(row, index)
return row[props.rowKey] || index
}
const getNestedValue = (obj, path) => {
if (!path) return undefined
return path.split('.').reduce((acc, part) => acc && acc[part], obj)
}
const formatCellValue = (value, column) => {
if (column.formatter) return column.formatter(value)
if (value === null || value === undefined) return '-'
return value
}
const toggleSelectAll = (event) => {
const checked = event.target.checked
const newSelectedRows = checked
? [...new Set([...props.selectedRows, ...props.data.map((row) => getRowKey(row))])]
: props.selectedRows.filter((id) => !props.data.map((row) => getRowKey(row)).includes(id))
emit('update:selectedRows', newSelectedRows)
}
const toggleSelectRow = (key) => {
const newSelectedRows = props.selectedRows.includes(key)
? props.selectedRows.filter((id) => id !== key)
: [...props.selectedRows, key]
emit('update:selectedRows', newSelectedRows)
}
const handleRowClick = (row, index) => {
if (props.rowClickable) {
emit('row-click', { row, index })
}
}
</script>
<style scoped>
.data-table {
display: flex;
flex-direction: column;
gap: var(--spacing-lg);
}
/* 工具栏 */
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-lg) var(--spacing-xl);
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius-xl);
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
gap: var(--spacing-md);
}
.selection-info {
font-size: var(--text-sm);
color: var(--text-secondary);
font-weight: var(--font-medium);
}
/* 表格容器 */
.table-container {
position: relative;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius-xl);
overflow: hidden;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--spacing-lg);
z-index: 10;
}
.loading-text {
color: var(--text-secondary);
font-size: var(--text-sm);
}
/* 表格头部 */
.table-header {
display: flex;
background: var(--table-header-bg);
border-bottom: 1px solid var(--table-border);
padding: var(--spacing-lg) var(--spacing-xl);
}
.header-cell {
font-size: var(--text-sm);
font-weight: var(--font-semibold);
color: var(--text-secondary);
display: flex;
align-items: center;
flex: 1;
}
.checkbox-cell {
width: 50px;
flex: none;
justify-content: center;
}
.actions-cell {
width: 120px;
flex: none;
justify-content: center;
}
/* 表格主体 */
.table-body {
min-height: 200px;
}
.table-row {
display: flex;
border-bottom: 1px solid var(--table-border);
padding: var(--spacing-lg) var(--spacing-xl);
transition: background-color var(--transition-normal);
}
.table-row:hover {
background-color: var(--table-row-hover);
}
.table-row:last-child {
border-bottom: none;
}
.table-row.selected {
background-color: var(--table-selected);
}
.table-row.clickable {
cursor: pointer;
}
.cell {
display: flex;
align-items: center;
flex: 1;
font-size: var(--text-sm);
color: var(--text-primary);
}
.checkbox {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
/* 操作按钮 */
.action-buttons {
display: flex;
gap: var(--spacing-sm);
}
.action-btn {
width: 32px;
height: 32px;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
}
.action-btn svg {
width: 16px;
height: 16px;
}
.edit-btn {
background: var(--primary);
color: var(--text-primary);
}
.edit-btn:hover {
background: var(--primary-hover);
}
.delete-btn {
background: var(--error);
color: var(--text-primary);
}
.delete-btn:hover {
background: var(--error-hover);
}
/* 空状态 */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: var(--spacing-3xl) var(--spacing-xl);
color: var(--text-tertiary);
}
.empty-state svg {
width: 48px;
height: 48px;
margin-bottom: var(--spacing-lg);
color: var(--text-disabled);
}
.empty-text {
font-size: var(--text-base);
color: var(--text-tertiary);
}
/* 移动端卡片布局 */
.mobile-cards {
display: none;
}
.data-card {
background: var(--table-row-bg);
border: 1px solid var(--table-border);
border-radius: var(--radius-lg);
margin-bottom: var(--spacing-md);
overflow: hidden;
transition: all var(--transition-normal);
}
.data-card:hover {
border-color: var(--border-secondary);
transform: translateY(-1px);
}
.data-card:active {
transform: scale(0.98);
transition: transform 0.1s ease;
}
.data-card.selected {
background-color: var(--table-selected);
border-color: var(--primary);
}
.data-card.clickable {
cursor: pointer;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--spacing-md);
border-bottom: 1px solid var(--table-border);
background: var(--bg-tertiary);
}
.card-selection {
margin-right: var(--spacing-sm);
}
.card-primary {
flex: 1;
}
.primary-value {
font-weight: 600;
font-size: var(--text-base);
color: var(--text-primary);
}
.card-actions {
display: flex;
gap: var(--spacing-xs);
}
.card-body {
padding: var(--spacing-md);
}
.card-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) 0;
border-bottom: 1px solid var(--border-tertiary);
}
.card-field:last-child {
border-bottom: none;
}
.field-label {
font-size: var(--text-sm);
color: var(--text-tertiary);
font-weight: 500;
}
.field-value {
font-size: var(--text-sm);
color: var(--text-secondary);
text-align: right;
}
/* 响应式设计 */
@media (max-width: 768px) {
.table-toolbar {
flex-direction: column;
align-items: stretch;
gap: var(--spacing-md);
}
.toolbar-left,
.toolbar-right {
justify-content: center;
}
/* 隐藏桌面表格,显示移动端卡片 */
.desktop-table {
display: none;
}
.mobile-cards {
display: block;
}
.action-buttons {
flex-direction: column;
gap: var(--spacing-xs);
}
.action-btn {
width: 36px;
height: 36px;
}
.action-btn svg {
width: 18px;
height: 18px;
}
}
/* 小屏幕设备进一步优化 */
@media (max-width: 480px) {
.data-card {
margin-bottom: var(--spacing-sm);
}
.card-header {
padding: var(--spacing-sm);
}
.card-body {
padding: var(--spacing-sm);
}
.primary-value {
font-size: var(--text-sm);
}
.field-label,
.field-value {
font-size: var(--text-xs);
}
.action-btn {
width: 32px;
height: 32px;
}
.action-btn svg {
width: 16px;
height: 16px;
}
}
</style>
+285
View File
@@ -0,0 +1,285 @@
<template>
<div class="relative w-full">
<slot v-if="!hasError" />
<!-- 错误状态 -->
<div
v-else
class="flex flex-col items-center justify-center min-h-[400px] p-8 text-center bg-zinc-900/30 border border-dashed border-zinc-800 rounded-3xl animate-in fade-in zoom-in duration-300"
>
<div class="relative mb-6">
<div class="absolute inset-0 blur-2xl bg-red-500/10 rounded-full" />
<div
class="relative flex items-center justify-center w-16 h-16 bg-zinc-950 border border-red-500/30 rounded-2xl text-red-500 shadow-xl shadow-black/40"
>
<AlertCircle :size="32" stroke-width="1.5" />
</div>
</div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight mb-2">{{ errorTitle }}</h3>
<p class="text-[10px] font-black text-zinc-500 uppercase tracking-widest max-w-xs mb-8">
{{ errorMessage }}
</p>
<div class="flex flex-wrap items-center justify-center gap-4">
<button
:disabled="retrying"
class="flex items-center gap-2 px-6 py-2.5 bg-zinc-950 border border-zinc-800 hover:border-blue-500/50 text-zinc-400 hover:text-zinc-100 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all shadow-lg shadow-black/20 disabled:opacity-50"
@click="handleRetry"
>
<RefreshCw :size="14" :class="{ 'animate-spin': retrying }" />
<span>{{ retrying ? '正在重试' : '重新尝试' }}</span>
</button>
<button
v-if="showDetails"
class="px-6 py-2.5 bg-zinc-900/50 border border-zinc-800 hover:border-zinc-700 text-zinc-500 hover:text-zinc-300 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
@click="toggleDetails"
>
{{ showErrorDetails ? '隐藏详细信息' : '查看详细信息' }}
</button>
</div>
<!-- 错误详情 -->
<transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div v-if="showErrorDetails" class="mt-8 w-full max-w-2xl text-left">
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl">
<div class="flex items-center gap-2 mb-3">
<div class="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>系统调试信息</span
>
</div>
<pre
class="text-[10px] font-mono text-zinc-500 leading-relaxed overflow-x-auto p-4 bg-black/30 rounded-xl whitespace-pre-wrap break-all"
>{{ errorDetails }}</pre
>
</div>
</div>
</transition>
</div>
</div>
</template>
<script lang="ts" setup>
import { AlertCircle, RefreshCw } from '@lucide/vue'
import { computed, ref } from 'vue'
interface Props {
error?: Error | string | null
errorTitle?: string
errorMessage?: string
showDetails?: boolean
onRetry?: () => void | Promise<void>
}
const props = withDefaults(defineProps<Props>(), {
error: null,
errorTitle: '加载失败',
errorMessage: '数据加载时发生错误,请稍后重试',
showDetails: false,
onRetry: undefined
})
const hasError = computed(() => !!props.error)
const retrying = ref(false)
const showErrorDetails = ref(false)
const errorDetails = computed(() => {
if (!props.error) return ''
if (typeof props.error === 'string') return props.error
return props.error.stack || props.error.message || String(props.error)
})
const handleRetry = async () => {
if (!props.onRetry || retrying.value) return
retrying.value = true
try {
await props.onRetry()
} catch (error) {
console.error('重试失败:', error)
} finally {
retrying.value = false
}
}
const toggleDetails = () => {
showErrorDetails.value = !showErrorDetails.value
}
</script>
<style scoped>
.error-boundary {
width: 100%;
height: 100%;
}
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
min-height: 200px;
}
.error-icon {
width: 64px;
height: 64px;
color: #ef4444;
margin-bottom: 16px;
}
.error-icon svg {
width: 100%;
height: 100%;
}
.error-title {
font-size: 20px;
font-weight: 600;
color: #f1f5f9;
margin: 0 0 8px 0;
}
.error-message {
font-size: 16px;
color: #94a3b8;
margin: 0 0 24px 0;
max-width: 400px;
line-height: 1.5;
}
.error-actions {
display: flex;
gap: 12px;
align-items: center;
}
.retry-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: linear-gradient(135deg, #4f46e5, #7c3aed);
border: none;
border-radius: 8px;
color: white;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.retry-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
transform: translateY(-1px);
}
.retry-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.retry-btn svg {
width: 16px;
height: 16px;
}
.details-btn {
padding: 10px 16px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #94a3b8;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.details-btn:hover {
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
color: #f1f5f9;
}
.error-details {
margin-top: 24px;
padding: 16px;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
text-align: left;
max-width: 600px;
width: 100%;
}
.error-details h4 {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #f1f5f9;
}
.error-details pre {
margin: 0;
font-size: 12px;
color: #94a3b8;
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow-y: auto;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.error-state {
padding: 24px 16px;
}
.error-icon {
width: 48px;
height: 48px;
}
.error-title {
font-size: 18px;
}
.error-message {
font-size: 14px;
}
.error-actions {
flex-direction: column;
width: 100%;
}
.retry-btn,
.details-btn {
width: 100%;
justify-content: center;
}
}
</style>
+173
View File
@@ -0,0 +1,173 @@
<template>
<div
:class="{
'fixed inset-0 z-[9999] bg-zinc-950/80 backdrop-blur-md': fullScreen,
'min-h-[200px] py-12': !fullScreen
}"
class="flex flex-col items-center justify-center animate-in fade-in duration-300"
>
<div class="flex flex-col items-center text-center max-w-md w-full px-6">
<!-- 加载动画 -->
<div class="mb-8">
<!-- 默认圆形加载器 -->
<div v-if="spinnerType === 'circle'" class="relative">
<div
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
/>
<div class="absolute inset-0 blur-lg bg-blue-500/20 rounded-full animate-pulse" />
</div>
<!-- 点状加载器 -->
<div v-else-if="spinnerType === 'dots'" class="flex gap-2">
<div
v-for="i in 3"
:key="i"
class="w-3 h-3 bg-blue-500 rounded-full animate-bounce"
:style="{ animationDelay: `${(i - 1) * 0.2}s` }"
/>
</div>
<!-- 脉冲加载器 -->
<div v-else-if="spinnerType === 'pulse'" class="relative w-12 h-12">
<div
v-for="i in 3"
:key="i"
class="absolute inset-0 border-2 border-blue-500 rounded-full animate-ping"
:style="{ animationDelay: `${(i - 1) * 0.4}s`, animationDuration: '2s' }"
/>
</div>
<!-- 条状加载器 -->
<div v-else-if="spinnerType === 'bars'" class="flex items-end gap-1.5 h-8">
<div
v-for="i in 5"
:key="i"
class="w-1.5 bg-blue-500 rounded-full animate-bounce"
:style="{
animationDelay: `${(i - 1) * 0.1}s`,
height: `${40 + Math.random() * 60}%`
}"
/>
</div>
</div>
<!-- 加载文本 -->
<div class="w-full space-y-2">
<h3 v-if="title" class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h3>
<p class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">{{ message }}</p>
<!-- 进度条 -->
<div v-if="showProgress" class="mt-8 space-y-2">
<div class="h-1.5 w-full bg-zinc-900 rounded-full overflow-hidden border border-zinc-800">
<div
class="h-full bg-blue-500 transition-all duration-300 shadow-[0_0_10px_rgba(59,130,246,0.5)]"
:style="{ width: `${progress}%` }"
/>
</div>
<div class="flex justify-between items-center px-1">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>加载进度</span
>
<span class="text-[10px] font-black text-blue-500 tracking-widest"
>{{ progress }}%</span
>
</div>
</div>
<!-- 加载步骤 -->
<div v-if="steps && steps.length > 0" class="mt-8 space-y-3 text-left">
<div
v-for="(step, index) in steps"
:key="index"
class="flex items-center gap-3 transition-all duration-300"
:class="{
'opacity-100': index <= currentStep,
'opacity-30': index > currentStep
}"
>
<div class="relative flex items-center justify-center w-6 h-6">
<div
v-if="index < currentStep"
class="flex items-center justify-center w-6 h-6 bg-blue-500/10 border border-blue-500/20 rounded-lg"
>
<Check :size="12" class="text-blue-500" />
</div>
<div
v-else-if="index === currentStep"
class="w-6 h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-lg animate-spin"
/>
<div
v-else
class="flex items-center justify-center w-6 h-6 bg-zinc-900 border border-zinc-800 rounded-lg"
>
<span class="text-[10px] font-black text-zinc-600">{{ index + 1 }}</span>
</div>
</div>
<span
class="text-[10px] font-black uppercase tracking-widest transition-colors"
:class="index === currentStep ? 'text-zinc-200' : 'text-zinc-600'"
>
{{ step }}
</span>
</div>
</div>
</div>
<!-- 取消按钮 -->
<button
v-if="showCancel && onCancel"
class="mt-10 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-xl transition-all"
@click="onCancel"
>
取消加载
</button>
</div>
</div>
</template>
<script lang="ts" setup>
import { Check } from '@lucide/vue'
interface Props {
title?: string
message?: string
spinnerType?: 'circle' | 'dots' | 'pulse' | 'bars'
fullScreen?: boolean
showProgress?: boolean
progress?: number
steps?: string[]
currentStep?: number
showCancel?: boolean
onCancel?: () => void
}
withDefaults(defineProps<Props>(), {
title: '',
message: '正在加载...',
spinnerType: 'circle',
fullScreen: false,
showProgress: false,
progress: 0,
steps: () => [],
currentStep: 0,
showCancel: false,
onCancel: undefined
})
</script>
<style scoped>
/* 保持必要的动画定义,如果 Tailwind 无法完全覆盖的话 */
@keyframes pulse-ring {
0% {
transform: scale(0.33);
opacity: 0;
}
80%,
100% {
opacity: 0;
}
50% {
opacity: 0.5;
}
}
</style>
+162
View File
@@ -0,0 +1,162 @@
<template>
<div v-if="totalPages > 1" class="flex flex-col sm:flex-row items-center justify-between gap-4 px-2 pt-4">
<!-- 分页信息 -->
<div class="text-[10px] font-black text-zinc-700 uppercase tracking-[0.2em] order-2 sm:order-1">
第 {{ currentPage }} 页 · 共 {{ totalPages }} 页
<span v-if="totalItems !== null"> (共 {{ totalItems }} {{ itemName }})</span>
</div>
<!-- 分页控制 -->
<div class="flex flex-wrap items-center justify-center gap-2 order-1 sm:order-2">
<!-- 首页 -->
<button
:disabled="currentPage === 1"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="首页"
@click="goToPage(1)"
>
<ChevronsLeft :size="18" />
</button>
<!-- 上一页 -->
<button
:disabled="currentPage === 1"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="上一页"
@click="goToPage(currentPage - 1)"
>
<ChevronLeft :size="18" />
</button>
<!-- 当前页码/按钮组 (移动端隐藏,只显示当前页) -->
<div class="flex items-center gap-2">
<button
v-for="page in displayedPages"
:key="page"
:class="[
'w-10 h-10 rounded-lg text-xs font-black transition-all shadow-lg',
currentPage === page
? 'bg-blue-600 text-white shadow-blue-900/20'
: 'border border-zinc-800 text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 shadow-none'
]"
@click="goToPage(page)"
>
{{ page }}
</button>
</div>
<!-- 下一页 -->
<button
:disabled="currentPage === totalPages"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="下一页"
@click="goToPage(currentPage + 1)"
>
<ChevronRight :size="18" />
</button>
<!-- 尾页 -->
<button
:disabled="currentPage === totalPages"
class="w-10 h-10 rounded-lg border border-zinc-800 flex items-center justify-center text-zinc-700 hover:text-blue-400 hover:border-blue-500/30 transition-all disabled:opacity-20 disabled:hover:text-zinc-700 disabled:hover:border-zinc-800"
title="尾页"
@click="goToPage(totalPages)"
>
<ChevronsRight :size="18" />
</button>
<!-- 跳转输入 -->
<div class="flex items-center ml-2 pl-2 border-l border-zinc-800">
<div class="relative group">
<input
v-model="jumpPageInput"
type="text"
class="w-12 h-10 bg-zinc-950 border border-zinc-800 rounded-lg text-center text-xs font-black text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-all"
placeholder="页"
@keyup.enter="handleJump"
>
</div>
<button
class="ml-2 px-3 h-10 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all"
@click="handleJump"
>
跳转
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight
} from '@lucide/vue'
const props = defineProps({
currentPage: {
type: Number,
required: true
},
totalPages: {
type: Number,
required: true
},
totalItems: {
type: Number,
default: null
},
itemName: {
type: String,
default: '项'
}
})
const emit = defineEmits(['update:currentPage', 'change'])
const jumpPageInput = ref('')
// 计算要显示的页码
const displayedPages = computed(() => {
const pages = []
const maxVisible = 3 // 手机端展示较少,桌面端也可以通过这个控制
let start = Math.max(1, props.currentPage - Math.floor(maxVisible / 2))
let end = Math.min(props.totalPages, start + maxVisible - 1)
if (end - start + 1 < maxVisible) {
start = Math.max(1, end - maxVisible + 1)
}
for (let i = start; i <= end; i++) {
pages.push(i)
}
return pages
})
const goToPage = (page) => {
if (page >= 1 && page <= props.totalPages && page !== props.currentPage) {
emit('update:currentPage', page)
emit('change', page)
}
}
const handleJump = () => {
const page = parseInt(jumpPageInput.value)
if (!isNaN(page) && page >= 1 && page <= props.totalPages) {
goToPage(page)
jumpPageInput.value = ''
} else {
// 简单的输入验证
jumpPageInput.value = ''
}
}
// 监听当前页变化,清空跳转输入
watch(() => props.currentPage, () => {
jumpPageInput.value = ''
})
</script>
+155
View File
@@ -0,0 +1,155 @@
<template>
<div ref="containerRef" class="relative inline-block">
<div class="cursor-pointer" @click="toggle">
<slot name="trigger" :is-open="isOpen" />
</div>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="transform scale-95 opacity-0 translate-y-2"
enter-to-class="transform scale-100 opacity-100 translate-y-0"
leave-active-class="transition duration-150 ease-in"
leave-from-class="transform scale-100 opacity-100 translate-y-0"
leave-to-class="transform scale-95 opacity-0 translate-y-2"
>
<div
v-if="isOpen"
ref="contentRef"
:style="contentStyle"
class="fixed z-[10000] bg-[#1e1e1e]/95 backdrop-blur-xl border border-white/10 rounded-xl shadow-2xl overflow-hidden"
@click.stop
>
<slot name="content" :close="close" />
</div>
</Transition>
</Teleport>
</div>
</template>
<script setup lang="ts">
import { ref, nextTick, watch, onUnmounted, type StyleValue } from 'vue'
const props = defineProps({
placement: {
type: String,
default: 'bottom-end' // bottom-start, bottom-end, top-start, top-end
},
offset: {
type: Number,
default: 8
}
})
const isOpen = ref(false)
const containerRef = ref<HTMLElement | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const contentStyle = ref<StyleValue>({})
const updatePosition = () => {
if (!isOpen.value || !containerRef.value || !contentRef.value) return
const triggerRect = containerRef.value.getBoundingClientRect()
const contentRect = contentRef.value.getBoundingClientRect()
const windowWidth = window.innerWidth
const windowHeight = window.innerHeight
let top = 0
let left = 0
// Vertical positioning base
if (props.placement.startsWith('top')) {
top = triggerRect.top - contentRect.height - props.offset
} else {
top = triggerRect.bottom + props.offset
}
// Horizontal positioning base
if (props.placement.endsWith('end')) {
left = triggerRect.right - contentRect.width
} else {
left = triggerRect.left
}
// Boundary checks
// Right edge
if (left + contentRect.width > windowWidth - 10) {
left = windowWidth - contentRect.width - 10
}
// Left edge
if (left < 10) {
left = 10
}
// Flip vertical if needed
if (props.placement.startsWith('bottom') && top + contentRect.height > windowHeight - 10) {
// Try top
const topSpace = triggerRect.top
if (topSpace > contentRect.height + props.offset) {
top = triggerRect.top - contentRect.height - props.offset
}
} else if (props.placement.startsWith('top') && top < 10) {
// Try bottom
const bottomSpace = windowHeight - triggerRect.bottom
if (bottomSpace > contentRect.height + props.offset) {
top = triggerRect.bottom + props.offset
}
}
contentStyle.value = {
top: `${top}px`,
left: `${left}px`
}
}
const close = () => {
isOpen.value = false
}
const toggle = async () => {
if (isOpen.value) {
close()
} else {
isOpen.value = true
await nextTick()
updatePosition()
}
}
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as Node
if (
containerRef.value &&
!containerRef.value.contains(target) &&
contentRef.value &&
!contentRef.value.contains(target)
) {
close()
}
}
const handleScrollOrResize = () => {
if (isOpen.value) {
updatePosition()
}
}
watch(isOpen, (val) => {
if (val) {
window.addEventListener('scroll', handleScrollOrResize, true)
window.addEventListener('resize', handleScrollOrResize)
// Use setTimeout to avoid immediate trigger if the click that opened it bubbles up
setTimeout(() => document.addEventListener('mousedown', handleClickOutside), 0)
} else {
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
document.removeEventListener('mousedown', handleClickOutside)
}
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScrollOrResize, true)
window.removeEventListener('resize', handleScrollOrResize)
document.removeEventListener('mousedown', handleClickOutside)
})
</script>
+469
View File
@@ -0,0 +1,469 @@
<template>
<div class="flex flex-col lg:flex-row items-center gap-4 w-full">
<!-- 搜索输入框 -->
<div class="relative flex-1 w-full">
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Search :size="16" class="text-zinc-500" />
</div>
<input
:value="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class="block w-full pl-11 pr-11 py-2.5 bg-zinc-950 border border-zinc-800 rounded-2xl text-xs font-bold text-zinc-200 placeholder:text-zinc-600 focus:outline-none focus:border-blue-500/50 focus:bg-blue-600/5 transition-all"
@input="$emit('update:searchQuery', $event.target.value)"
>
<button
v-if="searchQuery"
class="absolute inset-y-0 right-0 pr-4 flex items-center text-zinc-500 hover:text-zinc-300 transition-colors"
@click="$emit('update:searchQuery', '')"
>
<X :size="14" />
</button>
</div>
<!-- 过滤器容器 -->
<div v-if="filters.length > 0" class="flex flex-wrap items-center gap-3 w-full lg:w-auto">
<div v-for="filter in filters" :key="filter.key" class="flex-1 lg:flex-none min-w-[140px]">
<!-- 选择框过滤器 - 使用 CustomSelect -->
<CustomSelect
v-if="filter.type === 'select'"
:label="filter.label"
:value="getSelectValueLabel(filter)"
:options="filter.options.map((o) => o.label)"
class="w-full"
@change="(val) => updateSelectFilter(filter, val)"
/>
<!-- 日期范围过滤器 -->
<div
v-else-if="filter.type === 'dateRange'"
class="flex items-center gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-2xl"
>
<input
type="date"
:value="filterValues[filter.key]?.start || ''"
class="bg-transparent border-none text-[10px] font-bold text-zinc-300 focus:ring-0 px-2 py-1 w-28"
@input="updateDateRange(filter.key, 'start', $event.target.value)"
>
<span class="text-zinc-700 text-[10px] font-black uppercase">至</span>
<input
type="date"
:value="filterValues[filter.key]?.end || ''"
class="bg-transparent border-none text-[10px] font-bold text-zinc-300 focus:ring-0 px-2 py-1 w-28"
@input="updateDateRange(filter.key, 'end', $event.target.value)"
>
</div>
<!-- 多选过滤器 -->
<div v-else-if="filter.type === 'multiSelect'" ref="dropdownRef" class="relative">
<button
class="w-full flex items-center justify-between gap-2 px-3 py-2.5 bg-zinc-950 border border-zinc-800 rounded-2xl transition-all hover:border-zinc-700"
:class="{ 'border-blue-500/50 bg-blue-600/5': openDropdown === filter.key }"
@click="toggleDropdown(filter.key)"
>
<div class="flex flex-col items-start gap-0.5 overflow-hidden">
<span
v-if="filter.label"
class="text-[9px] font-black text-zinc-600 uppercase tracking-widest leading-none"
>{{ filter.label }}</span
>
<span class="text-[11px] font-bold text-zinc-300 truncate w-full text-left">{{
getMultiSelectLabel(filter)
}}</span>
</div>
<ChevronDown
:size="14"
class="text-zinc-500 shrink-0 transition-transform"
:class="{ 'rotate-180': openDropdown === filter.key }"
/>
</button>
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="openDropdown === filter.key"
class="absolute z-50 mt-2 w-full min-w-[200px] bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl overflow-hidden py-1"
>
<div class="max-h-60 overflow-y-auto custom-scrollbar">
<label
v-for="option in filter.options"
:key="option.value"
class="flex items-center gap-3 px-4 py-2.5 hover:bg-zinc-800 cursor-pointer group transition-colors"
>
<input
type="checkbox"
:checked="(filterValues[filter.key] || []).includes(option.value)"
class="w-3.5 h-3.5 rounded border-zinc-700 bg-zinc-950 text-blue-600 focus:ring-0 focus:ring-offset-0 transition-all"
@change="toggleMultiSelectOption(filter.key, option.value)"
>
<span class="text-xs font-bold text-zinc-400 group-hover:text-zinc-200">{{
option.label
}}</span>
</label>
</div>
</div>
</Transition>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div v-if="showActions" class="flex items-center gap-2 w-full lg:w-auto">
<button
v-if="hasActiveFilters"
class="flex-1 lg:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-900 border border-zinc-800 hover:border-red-500/30 hover:bg-red-500/5 text-zinc-400 hover:text-red-400 text-[10px] font-black uppercase tracking-widest rounded-2xl transition-all"
@click="clearAllFilters"
>
<RotateCcw :size="14" />
<span>清除筛选</span>
</button>
<slot name="actions" />
</div>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { Search, X, ChevronDown, RotateCcw } from '@lucide/vue'
import CustomSelect from './CustomSelect.vue'
const props = defineProps({
searchQuery: { type: String, default: '' },
searchPlaceholder: { type: String, default: '搜索...' },
filters: { type: Array, default: () => [] },
filterValues: { type: Object, default: () => ({}) },
showActions: { type: Boolean, default: true }
})
const emit = defineEmits(['update:searchQuery', 'update:filterValues', 'filter-change'])
const openDropdown = ref(null)
const dropdownRef = ref(null)
const hasActiveFilters = computed(() => {
return (
props.searchQuery ||
Object.values(props.filterValues).some((value) => {
if (Array.isArray(value)) return value.length > 0
if (typeof value === 'object' && value !== null) {
return Object.values(value).some((v) => v)
}
return value !== '' && value !== null && value !== undefined
})
)
})
const updateFilter = (key, value) => {
const newValues = { ...props.filterValues, [key]: value }
emit('update:filterValues', newValues)
emit('filter-change', { key, value, allValues: newValues })
}
const updateSelectFilter = (filter, label) => {
const option = filter.options.find((o) => o.label === label)
if (option) {
updateFilter(filter.key, option.value)
}
}
const getSelectValueLabel = (filter) => {
const value = props.filterValues[filter.key]
const option = filter.options.find((o) => o.value === value)
return option ? option.label : filter.placeholder || '请选择'
}
const updateDateRange = (key, type, value) => {
const currentRange = props.filterValues[key] || {}
const newRange = { ...currentRange, [type]: value }
updateFilter(key, newRange)
}
const toggleMultiSelectOption = (filterKey, optionValue) => {
const currentValues = props.filterValues[filterKey] || []
const newValues = currentValues.includes(optionValue)
? currentValues.filter((v) => v !== optionValue)
: [...currentValues, optionValue]
updateFilter(filterKey, newValues)
}
const toggleDropdown = (key) => {
openDropdown.value = openDropdown.value === key ? null : key
}
const getMultiSelectLabel = (filter) => {
const selectedValues = props.filterValues[filter.key] || []
if (selectedValues.length === 0) return filter.placeholder || '请选择'
if (selectedValues.length === 1) {
const option = filter.options.find((opt) => opt.value === selectedValues[0])
return option ? option.label : selectedValues[0]
}
return `已选 ${selectedValues.length} 项`
}
const clearAllFilters = () => {
emit('update:searchQuery', '')
const clearedValues = {}
props.filters.forEach((f) => {
if (f.type === 'multiSelect') clearedValues[f.key] = []
else if (f.type === 'dateRange') clearedValues[f.key] = { start: '', end: '' }
else clearedValues[f.key] = ''
})
emit('update:filterValues', clearedValues)
emit('filter-change', { key: 'all', value: null, allValues: clearedValues })
}
// 点击外部关闭下拉框
const handleClickOutside = (event) => {
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
openDropdown.value = null
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.search-filter {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: var(--spacing-lg);
padding: var(--spacing-xl);
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius-xl);
}
/* 搜索部分 */
.search-section {
flex: 1;
min-width: 300px;
}
.search-input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: var(--spacing-lg);
width: 20px;
height: 20px;
color: var(--text-quaternary);
z-index: 1;
}
.search-input {
width: 100%;
padding: var(--spacing-md) var(--spacing-lg) var(--spacing-md) 48px;
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
font-size: var(--text-sm);
transition: all var(--transition-normal);
}
.search-input:focus {
outline: none;
border-color: var(--input-border-focus);
box-shadow: var(--input-shadow-focus);
}
.search-input::placeholder {
color: var(--input-placeholder);
}
.clear-search-btn {
position: absolute;
right: var(--spacing-md);
width: 24px;
height: 24px;
background: none;
border: none;
color: var(--text-quaternary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: var(--radius-sm);
transition: all var(--transition-normal);
}
.clear-search-btn:hover {
background: var(--bg-quaternary);
color: var(--text-primary);
}
.clear-search-btn svg {
width: 16px;
height: 16px;
}
/* 过滤器部分 */
.filters-section {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-lg);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--spacing-sm);
}
.filter-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-secondary);
}
.filter-select,
.date-input {
padding: var(--spacing-md) var(--spacing-lg);
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
font-size: var(--text-sm);
cursor: pointer;
}
.filter-select:focus,
.date-input:focus {
outline: none;
border-color: var(--input-border-focus);
}
/* 日期范围过滤器 */
.date-range-filter {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.date-separator {
font-size: var(--text-sm);
color: var(--text-tertiary);
}
/* 多选过滤器 */
.multi-select-filter {
position: relative;
}
.multi-select-dropdown {
position: relative;
}
.multi-select-trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
background: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: var(--radius-lg);
color: var(--input-text);
font-size: var(--text-sm);
cursor: pointer;
min-width: 150px;
transition: all var(--transition-normal);
}
.multi-select-trigger:hover {
border-color: var(--input-border-focus);
}
.multi-select-trigger svg {
width: 16px;
height: 16px;
transition: transform var(--transition-normal);
}
.multi-select-dropdown.open .multi-select-trigger svg {
transform: rotate(180deg);
}
.multi-select-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
z-index: var(--z-dropdown);
max-height: 200px;
overflow-y: auto;
margin-top: var(--spacing-xs);
}
.multi-select-option {
display: flex;
align-items: center;
gap: var(--spacing-sm);
padding: var(--spacing-md) var(--spacing-lg);
cursor: pointer;
transition: background-color var(--transition-normal);
}
.multi-select-option:hover {
background: var(--bg-hover);
}
.multi-select-option .checkbox {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
/* 操作部分 */
.actions-section {
display: flex;
gap: var(--spacing-md);
}
/* 响应式设计 */
@media (max-width: 768px) {
.search-filter {
flex-direction: column;
align-items: stretch;
}
.search-section {
min-width: auto;
}
.filters-section {
flex-direction: column;
}
.date-range-filter {
flex-direction: column;
align-items: stretch;
}
.actions-section {
justify-content: center;
}
}
</style>
+191
View File
@@ -0,0 +1,191 @@
<template>
<div
class="relative overflow-hidden p-6 bg-zinc-900 border border-zinc-800 rounded-xl group hover:border-blue-500/30 transition-all duration-300 shadow-xl shadow-black/20"
:class="{ 'opacity-60 grayscale cursor-wait': isLoading }"
>
<!-- 背景装饰 -->
<div
class="absolute -right-4 -top-4 w-24 h-24 bg-blue-600/5 blur-3xl rounded-full group-hover:bg-blue-600/10 transition-colors"
/>
<div class="flex items-start justify-between relative z-10">
<div class="space-y-4 flex-1">
<div class="flex items-center gap-2">
<div
class="p-2.5 rounded-lg bg-zinc-950 border border-zinc-800 group-hover:border-blue-500/30 group-hover:bg-blue-600/5 transition-all duration-300"
:class="iconClass"
>
<component
:is="iconComponent"
:size="18"
class="text-zinc-400 group-hover:text-blue-400 transition-colors"
/>
</div>
<span
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest group-hover:text-zinc-500 transition-colors"
>{{ label }}</span
>
</div>
<div class="space-y-1">
<div class="flex items-baseline gap-2">
<h3 class="text-3xl font-black text-zinc-100 tracking-tight">
{{ formattedValue }}
</h3>
<div
v-if="change !== undefined"
:class="changeClass"
class="flex items-center gap-0.5 text-[10px] font-black uppercase px-1.5 py-0.5 rounded-md border"
>
<TrendingUp v-if="change > 0" :size="10" />
<TrendingDown v-else-if="change < 0" :size="10" />
<Minus v-else :size="10" />
<span>{{ Math.abs(change) }}%</span>
</div>
</div>
<p
v-if="subtitle"
class="text-[10px] font-bold text-zinc-600 group-hover:text-zinc-500 transition-colors"
>
{{ subtitle }}
</p>
</div>
</div>
<!-- 迷你趋势图 -->
<div v-if="trendData && trendData.length > 0" class="w-24 h-12 self-end mb-1">
<svg class="w-full h-full drop-shadow-[0_0_8px_rgba(59,130,246,0.2)]" viewBox="0 0 100 20">
<polyline
:points="trendPoints"
:stroke="trendColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
class="transition-all duration-500"
/>
</svg>
</div>
</div>
<!-- 加载动画 -->
<div
v-if="isLoading"
class="absolute inset-0 flex items-center justify-center bg-zinc-900/40 backdrop-blur-[1px]"
>
<div
class="w-6 h-6 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
/>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import {
Users,
Music,
Calendar,
Heart,
Bell,
TrendingUp,
TrendingDown,
Minus,
Database,
ShieldCheck,
Mail,
Zap,
Clock,
BarChart3,
MessageSquare
} from '@lucide/vue'
const props = defineProps({
label: { type: String, required: true },
value: { type: [String, Number], required: true },
icon: { type: String, required: true },
iconClass: { type: String, default: '' },
change: { type: Number, default: undefined },
changeLabel: { type: String, default: '' },
subtitle: { type: String, default: '' },
trendData: { type: Array, default: () => [] },
isLoading: { type: Boolean, default: false },
shouldAnimate: { type: Boolean, default: true },
format: { type: String, default: 'number' } // 'number', 'currency', 'percentage'
})
const iconComponent = computed(() => {
const icons = {
users: Users,
songs: Music,
schedule: Calendar,
votes: Heart,
notifications: Bell,
database: Database,
security: ShieldCheck,
email: Mail,
performance: Zap,
time: Clock,
chart: BarChart3,
messages: MessageSquare
}
return icons[props.icon] || icons.users
})
const changeClass = computed(() => {
if (props.change === undefined) return ''
if (props.change > 0) return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
if (props.change < 0) return 'bg-red-500/10 text-red-500 border-red-500/20'
return 'bg-zinc-800 text-zinc-500 border-zinc-700'
})
const formattedValue = computed(() => {
if (typeof props.value === 'string') return props.value
const num = Number(props.value)
if (isNaN(num)) return props.value
switch (props.format) {
case 'currency':
return new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(num)
case 'percentage':
return `${num}%`
case 'number':
default:
if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
return num.toLocaleString()
}
})
const minTrend = computed(() => Math.min(...(props.trendData || [0])))
const maxTrend = computed(() => Math.max(...(props.trendData || [1])))
const trendPoints = computed(() => {
if (!props.trendData || props.trendData.length < 2) return ''
const range = maxTrend.value - minTrend.value || 1
return props.trendData
.map((point, index) => {
const x = (index / (props.trendData.length - 1)) * 100
const y = 18 - ((point - minTrend.value) / range) * 16
return `${x},${y}`
})
.join(' ')
})
const trendColor = computed(() => {
if (!props.trendData || props.trendData.length < 2) {
if (props.change > 0) return '#10b981' // emerald-500
if (props.change < 0) return '#ef4444' // red-500
return '#3b82f6' // blue-500
}
const first = props.trendData[0]
const last = props.trendData[props.trendData.length - 1]
if (last > first) return '#10b981' // emerald-500
if (last < first) return '#ef4444' // red-500
return '#3b82f6' // blue-500
})
</script>
<style scoped></style>
+200
View File
@@ -0,0 +1,200 @@
<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-[2000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
@click="handleOverlayClick"
>
<div
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
@click.stop
>
<!-- 内容 -->
<div class="flex flex-col items-center p-8 text-center">
<!-- 图标 -->
<div
class="w-20 h-20 rounded-[2rem] flex items-center justify-center mb-6 transition-colors border"
:class="iconClasses"
>
<Icon :name="iconName" :size="40" />
</div>
<!-- 文字内容 -->
<div class="space-y-2 mb-8">
<h4 class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h4>
<p class="text-sm text-zinc-500 leading-relaxed font-medium whitespace-pre-line break-all">
{{ message }}
</p>
<!-- 可选的输入框 -->
<div v-if="showInput" class="pt-4 w-full">
<input
v-model="inputValue"
:type="inputType"
:placeholder="inputPlaceholder"
class="w-full bg-zinc-800/50 border border-zinc-700/50 rounded-xl px-4 py-3 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all text-sm"
@keyup.enter="handleConfirm"
/>
</div>
</div>
<!-- 操作按钮 -->
<div class="flex gap-3 w-full">
<button
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="handleCancel"
>
{{ cancelText }}
</button>
<button
class="flex-[2] px-6 py-4 text-white text-xs font-black rounded-2xl shadow-lg transition-all active:scale-95 uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
:class="confirmBtnClasses"
:disabled="loading || (showInput && !inputValue)"
@click="handleConfirm"
>
<Icon v-if="loading" name="loader" :size="16" class="animate-spin" />
{{ loading ? '处理中...' : confirmText }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import Icon from './Icon.vue'
const props = defineProps({
show: {
type: Boolean,
default: false
},
title: {
type: String,
default: '确认操作'
},
message: {
type: String,
required: true
},
type: {
type: String,
default: 'warning',
validator: (value) => ['warning', 'danger', 'info', 'success'].includes(value)
},
confirmText: {
type: String,
default: '确认'
},
cancelText: {
type: String,
default: '取消'
},
loading: {
type: Boolean,
default: false
},
closeOnOverlay: {
type: Boolean,
default: true
},
showInput: {
type: Boolean,
default: false
},
inputType: {
type: String,
default: 'text'
},
inputPlaceholder: {
type: String,
default: ''
}
})
const emit = defineEmits(['confirm', 'cancel', 'close', 'update:show'])
const inputValue = ref('')
watch(() => props.show, (newVal) => {
if (newVal) {
inputValue.value = ''
}
})
const handleConfirm = () => {
if (props.showInput) {
emit('confirm', inputValue.value)
} else {
emit('confirm')
}
}
const handleCancel = () => {
emit('cancel')
emit('close')
emit('update:show', false)
}
const handleOverlayClick = () => {
if (props.closeOnOverlay && !props.loading) {
handleCancel()
}
}
const iconName = computed(() => {
switch (props.type) {
case 'danger':
return 'alert-circle'
case 'success':
return 'success'
case 'info':
return 'info'
case 'warning':
default:
return 'alert-triangle'
}
})
const iconClasses = computed(() => {
switch (props.type) {
case 'danger':
return 'bg-red-500/10 text-red-500 border-red-500/20 shadow-red-900/5'
case 'success':
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20 shadow-emerald-900/5'
case 'info':
return 'bg-blue-500/10 text-blue-500 border-blue-500/20 shadow-blue-900/5'
case 'warning':
default:
return 'bg-amber-500/10 text-amber-500 border-amber-500/20 shadow-amber-900/5'
}
})
const confirmBtnClasses = computed(() => {
switch (props.type) {
case 'danger':
return 'bg-red-600 hover:bg-red-500 shadow-red-900/20'
case 'success':
return 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-900/20'
case 'info':
return 'bg-blue-600 hover:bg-blue-500 shadow-blue-900/20'
case 'warning':
default:
return 'bg-amber-600 hover:bg-amber-500 shadow-amber-900/20'
}
})
</script>
<style scoped></style>
+431
View File
@@ -0,0 +1,431 @@
<template>
<svg
:class="['icon', `icon-${name}`, className]"
:height="size"
:style="{ color }"
:width="size"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<!-- 消息图标 (圆形气泡) -->
<g v-if="name === 'message-circle'">
<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z" />
</g>
<!-- 播放图标 -->
<g v-else-if="name === 'play'" fill="currentColor" stroke="none" stroke-width="0">
<path
d="M6.77 2.35Q5.95 1.9 5.08 2.06Q4.2 2.23 3.61 2.9Q3.02 3.58 3.02 4.51L3.02 19.46Q3.02 20.4 3.61 21.08Q4.2 21.77 5.08 21.94Q5.95 22.1 6.77 21.65L19.73 14.16Q20.54 13.7 20.83 12.85Q21.12 12 20.83 11.15Q20.54 10.3 19.73 9.84L6.77 2.35Z"
/>
</g>
<!-- 播放/暂停 -->
<g v-else-if="name === 'pause'">
<rect fill="currentColor" height="16" width="4" x="6" y="4" />
<rect fill="currentColor" height="16" width="4" x="14" y="4" />
</g>
<!-- 播放圈图标 (单曲播放等使用) -->
<g v-else-if="name === 'play-circle'">
<circle cx="12" cy="12" r="10" />
<polygon points="10 8 16 12 10 16 10 8" />
</g>
<!-- 停止图标 -->
<rect v-else-if="name === 'stop'" fill="currentColor" height="14" width="14" x="5" y="5" />
<!-- 音量图标 -->
<g v-else-if="name === 'volume'">
<polygon points="11,5 6,9 2,9 2,15 6,15 11,19" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07" />
</g>
<!-- 静音图标 -->
<g v-else-if="name === 'volume-mute'">
<polygon points="11,5 6,9 2,9 2,15 6,15 11,19" />
<line x1="23" x2="17" y1="9" y2="15" />
<line x1="17" x2="23" y1="9" y2="15" />
</g>
<!-- 左箭头 -->
<g v-else-if="name === 'chevron-left'">
<polyline points="15,18 9,12 15,6" />
</g>
<!-- 右箭头 -->
<g v-else-if="name === 'chevron-right'">
<polyline points="9,18 15,12 9,6" />
</g>
<!-- 上箭头 -->
<g v-else-if="name === 'chevron-up'">
<polyline points="18,15 12,9 6,15" />
</g>
<!-- 下箭头 -->
<g v-else-if="name === 'chevron-down'">
<polyline points="6,9 12,15 18,9" />
</g>
<!-- 箭头图标 -->
<g v-else-if="name === 'arrow-left' || name === 'back'">
<line x1="19" x2="5" y1="12" y2="12" />
<polyline points="12 19 5 12 12 5" />
</g>
<g v-else-if="name === 'arrow-right'">
<line x1="5" x2="19" y1="12" y2="12" />
<polyline points="12 5 19 12 12 19" />
</g>
<!-- 心形/点赞图标 -->
<path
v-else-if="name === 'heart'"
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"
/>
<!-- 音乐图标 -->
<g v-else-if="name === 'music'">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</g>
<!-- 视频图标 -->
<g v-else-if="name === 'video'">
<path d="m22 8-6 4 6 4V8Z" />
<rect width="14" height="12" x="2" y="6" rx="2" ry="2" />
</g>
<!-- 目标/选中图标 -->
<g v-else-if="name === 'target'">
<circle cx="12" cy="12" r="10" />
<circle cx="12" cy="12" r="6" />
<circle cx="12" cy="12" r="2" />
</g>
<!-- 拇指向上 -->
<g v-else-if="name === 'thumbs-up'">
<path
d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"
/>
</g>
<!-- 扬声器/广播图标 -->
<g v-else-if="name === 'speaker'">
<rect height="20" rx="2" ry="2" width="16" x="4" y="2" />
<circle cx="12" cy="14" r="4" />
<circle cx="12" cy="6" r="2" />
</g>
<!-- 通知图标 -->
<g v-else-if="name === 'bell'">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</g>
<g v-else-if="name === 'sun'">
<circle cx="12" cy="12" r="4" />
<line x1="12" x2="12" y1="2" y2="4" />
<line x1="12" x2="12" y1="20" y2="22" />
<line x1="4.22" x2="5.64" y1="4.22" y2="5.64" />
<line x1="18.36" x2="19.78" y1="18.36" y2="19.78" />
<line x1="2" x2="4" y1="12" y2="12" />
<line x1="20" x2="22" y1="12" y2="12" />
<line x1="18.36" x2="19.78" y1="5.64" y2="4.22" />
<line x1="4.22" x2="5.64" y1="19.78" y2="18.36" />
</g>
<g v-else-if="name === 'moon'">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</g>
<!-- 日历图标 -->
<g v-else-if="name === 'calendar'">
<rect height="18" rx="2" ry="2" width="18" x="3" y="4" />
<line x1="16" x2="16" y1="2" y2="6" />
<line x1="8" x2="8" y1="2" y2="6" />
<line x1="3" x2="21" y1="10" y2="10" />
</g>
<!-- 时钟图标 -->
<g v-else-if="name === 'clock'">
<circle cx="12" cy="12" r="10" />
<polyline points="12,6 12,12 16,14" />
</g>
<!-- 用户图标 -->
<g v-else-if="name === 'user'">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</g>
<!-- 用户组/多用户图标 -->
<g v-else-if="name === 'users' || name === 'group'">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</g>
<!-- 设置图标 -->
<g v-else-if="name === 'settings'" fill="currentColor" stroke="none" stroke-width="0">
<path
d="M12.02 9Q12.84 9 13.52 9.41Q14.21 9.82 14.6 10.51Q15 11.21 15 12.02Q15 12.84 14.6 13.52Q14.21 14.21 13.52 14.6Q12.84 15 12 15Q11.18 15 10.49 14.59Q9.79 14.18 9.4 13.49Q9 12.79 9 11.98Q9 11.16 9.41 10.48Q9.82 9.79 10.51 9.4Q11.21 9 12.02 9ZM12.05 7.51Q10.82 7.51 9.78 8.11Q8.74 8.71 8.12 9.73Q7.51 10.75 7.51 11.98Q7.51 13.2 8.11 14.24Q8.71 15.29 9.74 15.9Q10.78 16.51 12 16.51Q13.22 16.51 14.26 15.91Q15.29 15.31 15.9 14.28Q16.51 13.25 16.51 12.02Q16.51 10.8 15.91 9.77Q15.31 8.74 14.29 8.12Q13.27 7.51 12.05 7.51ZM13.56 22.66Q14.02 22.66 14.4 22.4Q14.78 22.15 14.98 21.74L15.79 19.85L15.98 19.75Q16.42 19.49 16.85 19.22L18.91 19.49Q19.39 19.54 19.8 19.33Q20.21 19.13 20.45 18.72L22.03 15.98Q22.27 15.58 22.24 15.12Q22.2 14.66 21.94 14.28L20.69 12.6L20.69 11.42L21.94 9.74Q22.22 9.36 22.25 8.9Q22.27 8.45 22.03 8.04L20.45 5.28Q20.23 4.9 19.82 4.68Q19.42 4.46 18.94 4.51L16.85 4.78Q16.42 4.46 15.86 4.2L15.02 2.23Q14.83 1.82 14.45 1.56Q14.06 1.3 13.61 1.3L10.44 1.3Q9.98 1.3 9.59 1.56Q9.19 1.82 9 2.23L8.16 4.2L7.92 4.34Q7.73 4.44 7.55 4.54Q7.37 4.63 7.2 4.75L5.09 4.51Q4.63 4.44 4.21 4.64Q3.79 4.85 3.55 5.26L1.97 8.02Q1.73 8.4 1.76 8.87Q1.8 9.34 2.06 9.72L3.34 11.42L3.34 12.53L2.06 14.23Q1.78 14.62 1.75 15.07Q1.73 15.53 1.97 15.94L3.53 18.67Q3.77 19.08 4.18 19.28Q4.58 19.49 5.06 19.44L7.13 19.2Q7.8 19.63 8.14 19.8L8.98 21.72Q9.14 22.15 9.53 22.4Q9.91 22.66 10.39 22.66L13.56 22.66ZM7.08 17.66L4.8 17.9L3.22 15.1L4.58 13.27Q4.8 12.94 4.8 12.6Q4.78 12.31 4.78 11.95Q4.78 11.59 4.8 11.3Q4.8 10.92 4.58 10.61L3.22 8.74L4.85 5.95L7.15 6.24Q7.37 6.26 7.55 6.22Q7.73 6.17 7.87 6.07Q8.3 5.76 8.93 5.47Q9.29 5.28 9.43 4.94L10.39 2.78L13.61 2.81L14.52 4.94Q14.64 5.26 15.02 5.47Q15.55 5.71 16.1 6.1Q16.46 6.34 16.82 6.26L19.13 6.02L20.71 8.83L19.37 10.63Q19.1 10.92 19.15 11.33Q19.18 11.64 19.18 12.01Q19.18 12.38 19.15 12.67Q19.13 13.06 19.37 13.37L20.71 15.19L19.08 17.98L16.85 17.71Q16.66 17.69 16.46 17.72Q16.27 17.76 16.13 17.88Q15.6 18.26 15 18.53Q14.83 18.62 14.69 18.76Q14.54 18.89 14.47 19.06L13.56 21.14L10.34 21.12L9.46 19.03Q9.29 18.67 8.95 18.5Q8.47 18.29 7.85 17.88Q7.58 17.69 7.25 17.69Z"
/>
</g>
<!-- 关闭/叉叉图标 -->
<g v-else-if="name === 'x' || name === 'close'">
<line x1="18" x2="6" y1="6" y2="18" />
<line x1="6" x2="18" y1="6" y2="18" />
</g>
<!-- 信息图标 -->
<g v-else-if="name === 'info'">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<circle cx="12" cy="8" r="1" fill="currentColor" />
</g>
<!-- 成功/勾选图标 -->
<g v-else-if="name === 'check' || name === 'success'">
<polyline points="20 6 9 17 4 12" />
</g>
<!-- 错误/叉叉图标 -->
<g v-else-if="name === 'error' || name === 'x-circle' || name === 'alert-circle'">
<circle
cx="12"
cy="12"
r="10"
fill="currentColor"
fill-opacity="0.1"
stroke="currentColor"
stroke-width="2"
/>
<line x1="15" y1="9" x2="9" y2="15" stroke="currentColor" stroke-width="2" />
<line x1="9" y1="9" x2="15" y2="15" stroke="currentColor" stroke-width="2" />
</g>
<!-- 警告图标 -->
<g v-else-if="name === 'warning' || name === 'alert-triangle'">
<path
d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"
fill="currentColor"
fill-opacity="0.1"
stroke="currentColor"
stroke-width="2"
/>
<line x1="12" y1="9" x2="12" y2="13" stroke="currentColor" stroke-width="2" />
<circle cx="12" cy="17" r="1" fill="currentColor" stroke="none" />
</g>
<!-- 上一首图标 -->
<g v-else-if="name === 'skip-back' || name === 'previous'" fill="currentColor" stroke="none" stroke-width="0">
<path
d="M9.07 10.82Q8.64 11.09 8.47 11.54Q8.3 12 8.47 12.46Q8.64 12.91 9.07 13.18L17.71 18.17Q18.17 18.41 18.64 18.31Q19.1 18.22 19.43 17.84Q19.75 17.47 19.75 16.97L19.75 7.01Q19.75 6.5 19.43 6.14Q19.1 5.78 18.62 5.69Q18.14 5.59 17.71 5.83L9.07 10.82ZM5.26 17.71Q5.66 17.71 5.95 17.42Q6.24 17.14 6.24 16.7L6.24 7.18Q6.24 6.77 5.95 6.47Q5.66 6.17 5.26 6.17Q4.85 6.17 4.55 6.47Q4.25 6.77 4.25 7.18L4.25 16.7Q4.25 17.14 4.55 17.42Q4.85 17.71 5.26 17.71Z"
/>
</g>
<!-- 下一首图标 -->
<g v-else-if="name === 'skip-forward' || name === 'next'" fill="currentColor" stroke="none" stroke-width="0">
<path
d="M6.29 5.83Q5.86 5.59 5.38 5.69Q4.9 5.78 4.57 6.14Q4.25 6.5 4.25 7.01L4.25 16.97Q4.25 17.47 4.57 17.84Q4.9 18.22 5.36 18.31Q5.83 18.41 6.29 18.17L14.9 13.18Q15.34 12.91 15.5 12.46Q15.67 12 15.5 11.54Q15.34 11.09 14.9 10.82L6.29 5.83ZM18.74 17.71Q19.15 17.71 19.45 17.42Q19.75 17.14 19.75 16.7L19.75 7.18Q19.75 6.77 19.45 6.47Q19.15 6.17 18.74 6.17Q18.34 6.17 18.05 6.47Q17.76 6.77 17.76 7.18L17.76 16.7Q17.76 17.14 18.05 17.42Q18.34 17.71 18.74 17.71Z"
/>
</g>
<!-- 快退图标 -->
<g v-else-if="name === 'rewind'">
<polygon fill="currentColor" points="11,19 2,12 11,5" />
<polygon fill="currentColor" points="22,19 13,12 22,5" />
</g>
<!-- 快进图标 -->
<g v-else-if="name === 'fast-forward'">
<polygon fill="currentColor" points="13,19 22,12 13,5" />
<polygon fill="currentColor" points="2,19 11,12 2,5" />
</g>
<!-- 歌词图标 -->
<g v-else-if="name === 'lyrics'">
<!-- 音符基础形状 -->
<path d="M9 18V5l12-2v13" fill="none" stroke="currentColor" stroke-width="1.5" />
<circle cx="6" cy="18" fill="currentColor" r="3" />
<circle cx="18" cy="16" fill="currentColor" r="3" />
<!-- 歌词文本线条 -->
<line
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.5"
x1="2"
x2="7"
y1="8"
y2="8"
/>
<line
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.5"
x1="2"
x2="9"
y1="12"
y2="12"
/>
<line
stroke="currentColor"
stroke-linecap="round"
stroke-width="1.5"
x1="2"
x2="6"
y1="16"
y2="16"
/>
</g>
<!-- 刷新图标 -->
<g v-else-if="name === 'refresh' || name === 'rotate-cw'">
<polyline points="23 4 23 10 17 10" />
<polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</g>
<!-- 垃圾桶/删除图标 -->
<g v-else-if="name === 'trash' || name === 'delete'">
<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" x2="10" y1="11" y2="17" />
<line x1="14" x2="14" y1="11" y2="17" />
</g>
<!-- 加载中图标 -->
<g v-else-if="name === 'loader' || name === 'spinner'">
<line x1="12" x2="12" y1="2" y2="6" />
<line x1="12" x2="12" y1="18" y2="22" />
<line x1="4.93" x2="7.76" y1="4.93" y2="7.76" />
<line x1="16.24" x2="19.07" y1="16.24" y2="19.07" />
<line x1="2" x2="6" y1="12" y2="12" />
<line x1="18" x2="22" y1="12" y2="12" />
<line x1="4.93" x2="7.76" y1="19.07" y2="16.24" />
<line x1="16.24" x2="19.07" y1="7.76" y2="4.93" />
</g>
<!-- 加号/添加图标 -->
<g v-else-if="name === 'plus' || name === 'add'">
<line x1="12" x2="12" y1="5" y2="19" />
<line x1="5" x2="19" y1="12" y2="12" />
</g>
<!-- 历史记录图标 -->
<g v-else-if="name === 'history'">
<path d="M12 2a10 10 0 1 0 10 10 10 10 0 0 0-10-10z" />
<polyline points="12 6 12 12 16 14" />
</g>
<!-- 歌单/列表图标 -->
<g v-else-if="name === 'playlist' || name === 'list' || name === 'order' || name === 'list-ordered'" fill="currentColor" stroke="none" stroke-width="0">
<path
d="M11.76 5.06Q12.07 5.06 12.3 4.85Q12.53 4.63 12.53 4.32Q12.53 4.01 12.3 3.79Q12.07 3.58 11.76 3.58L3.29 3.58Q2.98 3.58 2.75 3.79Q2.52 4.01 2.52 4.32Q2.52 4.63 2.75 4.85Q2.98 5.06 3.29 5.06L11.76 5.06ZM12.53 10.32Q12.53 10.01 12.3 9.79Q12.07 9.58 11.76 9.58L3.29 9.58Q2.98 9.58 2.75 9.79Q2.52 10.01 2.52 10.32Q2.52 10.63 2.75 10.85Q2.98 11.06 3.29 11.06L11.76 11.06Q12.07 11.06 12.3 10.85Q12.53 10.63 12.53 10.32ZM3.29 15.58Q2.98 15.58 2.75 15.79Q2.52 16.01 2.52 16.32Q2.52 16.63 2.75 16.85Q2.98 17.06 3.29 17.06L7.75 17.06Q8.06 17.06 8.28 16.85Q8.5 16.63 8.5 16.32Q8.5 16.01 8.28 15.79Q8.06 15.58 7.75 15.58L3.29 15.58Z"
/>
<path
d="M18.36 3.89Q17.86 3.94 17.53 4.31Q17.21 4.68 17.21 5.21L17.23 6.91L17.23 13.13Q17.23 13.44 17.11 13.67Q16.99 13.9 16.75 14.09Q16.42 14.35 15.41 14.74L15.34 14.76Q14.14 15.24 13.38 15.74Q12.62 16.25 12.26 17.16Q12.05 17.76 12.13 18.36Q12.22 18.96 12.6 19.44Q13.1 20.06 13.94 20.3Q14.78 20.54 15.53 20.45Q17.35 20.23 18.04 19.01Q18.72 17.78 18.72 16.37L18.72 6.94Q18.77 6.53 18.92 6.38Q19.08 6.24 19.46 6.12Q20.09 5.88 20.38 5.69Q21.05 5.28 21.3 4.73Q21.55 4.18 21.41 3.5L18.36 3.89ZM15.36 18.98Q14.83 19.03 14.41 18.91Q13.99 18.79 13.78 18.53Q13.66 18.34 13.62 18.13Q13.58 17.93 13.68 17.69Q13.87 17.18 14.41 16.86Q14.95 16.54 15.89 16.15L15.96 16.13L16.1 16.06Q16.42 15.94 16.72 15.8Q17.02 15.67 17.21 15.55L17.21 16.63Q17.21 17.4 16.85 18.13Q16.49 18.86 15.36 18.98Z"
/>
</g>
<!-- 登出/退出图标 -->
<g v-else-if="name === 'logout' || name === 'log-out'">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" x2="9" y1="12" y2="12" />
</g>
<!-- 下载图标 -->
<g v-else-if="name === 'download'">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" x2="12" y1="15" y2="3" />
</g>
<!-- 重播/循环图标 -->
<g v-else-if="name === 'repeat'">
<polyline points="17 1 21 5 17 9" />
<path d="M3 11V9a4 4 0 0 1 4-4h14" />
<polyline points="7 23 3 19 7 15" />
<path d="M21 13v2a4 4 0 0 1-4 4H3" />
</g>
<!-- 单曲循环图标 -->
<g v-else-if="name === 'repeat-one'" fill="currentColor" stroke="none" stroke-width="0">
<path
d="M12.65 14.95Q12.94 14.95 13.09 14.78Q13.25 14.62 13.25 14.3L13.25 9.67Q13.25 9.12 12.89 9.05Q12.53 8.98 11.95 9.24L10.97 9.82Q10.61 10.01 10.54 10.2Q10.46 10.39 10.54 10.63Q10.58 10.8 10.81 10.86Q11.04 10.92 11.3 10.78L12.07 10.34L12.07 14.33Q12.07 14.64 12.22 14.8Q12.36 14.95 12.65 14.95Z"
/>
<path
d="M16.66 7.27L19.63 7.27Q19.78 7.27 19.88 7.16Q19.99 7.06 19.99 6.91Q19.99 6.77 19.87 6.65L16.99 3.84Q16.9 3.72 16.75 3.72Q16.61 3.72 16.5 3.83Q16.39 3.94 16.39 4.08L16.39 5.76L7.32 5.76Q5.62 5.76 4.18 6.6Q2.74 7.44 1.9 8.88Q1.06 10.32 1.06 12Q1.06 13.63 1.85 15.05Q2.02 15.36 2.33 15.42Q2.64 15.48 2.88 15.34Q3.14 15.17 3.23 14.87Q3.31 14.57 3.17 14.3Q2.57 13.27 2.57 12Q2.57 10.7 3.2 9.62Q3.84 8.54 4.93 7.91Q6.02 7.27 7.32 7.27ZM22.15 8.95Q22.01 8.69 21.7 8.6Q21.38 8.52 21.12 8.66Q20.86 8.81 20.77 9.11Q20.69 9.41 20.83 9.67Q21.43 10.73 21.43 11.98Q21.43 13.27 20.8 14.36Q20.16 15.46 19.07 16.09Q17.98 16.73 16.68 16.73L7.34 16.73L4.37 16.73Q4.22 16.73 4.13 16.84Q4.03 16.94 4.03 17.09Q4.03 17.23 4.13 17.33L7.01 20.16Q7.1 20.26 7.25 20.26Q7.39 20.26 7.5 20.15Q7.61 20.04 7.61 19.9L7.61 18.22L16.68 18.22Q18.38 18.22 19.82 17.38Q21.26 16.54 22.1 15.1Q22.94 13.66 22.94 11.98Q22.94 10.32 22.15 8.95Z"
/>
</g>
<!-- 搜索图标 -->
<g v-else-if="name === 'search'">
<circle cx="11" cy="11" r="8" />
<line x1="21" x2="16.65" y1="21" y2="16.65" />
</g>
<!-- 上传图标 -->
<g v-else-if="name === 'upload'">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</g>
<!-- 云上传图标 -->
<g v-else-if="name === 'cloud-upload'">
<path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3" />
<polyline points="16 16 12 12 8 16" />
<line x1="12" y1="12" x2="12" y2="21" />
</g>
<!-- 默认图标(如果没有匹配的图标) -->
<circle v-else cx="12" cy="12" r="10" />
</svg>
</template>
<script setup>
defineProps({
name: {
type: String,
required: true
},
size: {
type: [String, Number],
default: 24
},
color: {
type: String,
default: 'currentColor'
},
className: {
type: String,
default: ''
}
})
</script>
<style scoped>
.icon {
display: inline-block;
vertical-align: middle;
transition: all 0.2s ease;
/* 确保SVG在所有设备上渲染一致 */
shape-rendering: geometricPrecision;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.icon:hover {
transform: scale(1.05);
}
</style>
File diff suppressed because it is too large Load Diff
+140
View File
@@ -0,0 +1,140 @@
<template>
<div ref="containerEl" class="marquee-container">
<div ref="contentEl" :class="{ scrolling }" class="marquee-content">
<span class="text-item">{{ text }}</span>
<span v-if="scrolling" class="text-item">{{ text }}</span>
</div>
</div>
</template>
<script setup>
import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const props = defineProps({
text: {
type: String,
required: true
},
speed: {
type: Number,
default: 40 // pixels per second
},
activated: {
type: Boolean,
default: true // 默认为true保持向后兼容性
}
})
const containerEl = ref(null)
const contentEl = ref(null)
const scrolling = ref(false)
const isMobile = ref(false)
let mql = null
const checkOverflow = async () => {
if (!containerEl.value || !contentEl.value) return
// Reset scrolling to measure a single item's width.
scrolling.value = false
await nextTick()
if (!isMobile.value || !props.activated) {
return // No scrolling on desktop or when not activated.
}
const containerWidth = containerEl.value.offsetWidth
const contentWidth = contentEl.value.scrollWidth
if (contentWidth > containerWidth) {
scrolling.value = true
await nextTick() // Wait for the second span to render.
// The animation scrolls by 50% of the total width.
// This distance is equivalent to the width of the first span (including its padding).
const scrollDistance = contentEl.value.firstElementChild.offsetWidth
const animationDuration = scrollDistance / props.speed
contentEl.value.style.setProperty('--duration', `${animationDuration}s`)
}
}
const handleScreenChange = () => {
if (mql) {
isMobile.value = mql.matches
}
checkOverflow()
}
onMounted(() => {
mql = window.matchMedia('(max-width: 768px)')
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
handleScreenChange() // Initial check once visible.
observer.disconnect()
}
},
{ threshold: 0.01 }
)
if (containerEl.value) {
observer.observe(containerEl.value)
}
mql.addEventListener('change', handleScreenChange)
window.addEventListener('resize', handleScreenChange)
})
onUnmounted(() => {
if (mql) {
mql.removeEventListener('change', handleScreenChange)
}
window.removeEventListener('resize', handleScreenChange)
})
watch(
() => props.text,
() => {
checkOverflow()
}
)
watch(
() => props.activated,
() => {
checkOverflow()
}
)
</script>
<style scoped>
.marquee-container {
overflow: hidden;
width: 100%;
white-space: nowrap;
}
.marquee-content {
display: inline-block;
}
.marquee-content.scrolling {
animation: marquee var(--duration) linear infinite;
will-change: transform;
}
.text-item {
display: inline-block;
padding-right: 50px; /* Space between repeated text */
vertical-align: middle;
}
@keyframes marquee {
0% {
transform: translateX(0);
}
100% {
transform: translateX(-50%);
}
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<transition name="notification">
<div
v-if="show"
:class="{
success: type === 'success',
error: type === 'error',
info: type === 'info'
}"
class="notification"
>
<div class="notification-icon">
<Icon v-if="type === 'success'" :size="16" name="success" />
<Icon v-else-if="type === 'error'" :size="16" name="error" />
<Icon v-else :size="16" name="info" />
</div>
<div class="notification-content">
{{ message }}
</div>
<button class="notification-close" @click="$emit('close')">
<Icon :size="16" name="close" />
</button>
<!-- 进度条 -->
<div v-if="autoClose" class="notification-progress">
<div :style="{ animationDuration: `${duration}ms` }" class="notification-progress-bar" />
</div>
</div>
</transition>
</template>
<script setup>
import Icon from './Icon.vue'
defineProps({
show: {
type: Boolean,
default: false
},
message: {
type: String,
default: ''
},
type: {
type: String,
default: 'info', // 'success', 'error', 'info'
validator: (value) => ['success', 'error', 'info'].includes(value)
},
autoClose: {
type: Boolean,
default: true
},
duration: {
type: Number,
default: 3000 // 默认3秒后自动关闭
}
})
defineEmits(['close'])
</script>
<style scoped>
.notification {
position: fixed;
top: 20px;
right: 20px;
z-index: 99999;
display: flex;
align-items: center;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
background: #21242d;
color: #ffffff;
max-width: 400px;
min-width: 300px;
font-family: 'MiSans', sans-serif;
overflow: hidden; /* 确保进度条不会溢出 */
}
.notification.success {
border-left: 4px solid #10b981;
}
.notification.error {
border-left: 4px solid #ef4444;
}
.notification.info {
border-left: 4px solid #0b5afe;
}
.notification-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
margin-right: 16px;
font-weight: bold;
font-size: 16px;
}
.success .notification-icon {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.error .notification-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.info .notification-icon {
background: rgba(11, 90, 254, 0.2);
color: #0b5afe;
}
.notification-content {
flex: 1;
font-size: 14px;
line-height: 1.4;
}
.notification-close {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 20px;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 12px;
padding: 0;
transition: color 0.2s;
}
.notification-close:hover {
color: #ffffff;
}
/* 进度条 */
.notification-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.1);
}
.notification-progress-bar {
height: 100%;
width: 100%;
transform-origin: left center;
animation: progress-shrink linear forwards;
}
.success .notification-progress-bar {
background-color: #10b981;
}
.error .notification-progress-bar {
background-color: #ef4444;
}
.info .notification-progress-bar {
background-color: #0b5afe;
}
@keyframes progress-shrink {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
/* 动画效果 */
.notification-enter-active {
animation: notification-in 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.notification-leave-active {
animation: notification-out 0.3s cubic-bezier(0.55, 0.085, 0.68, 0.53);
}
@keyframes notification-in {
0% {
opacity: 0;
transform: translateX(40px) scale(0.9);
}
50% {
transform: translateX(-5px) scale(1.02);
}
100% {
opacity: 1;
transform: translateX(0) scale(1);
}
}
@keyframes notification-out {
0% {
opacity: 1;
transform: translateX(0) scale(1);
}
100% {
opacity: 0;
transform: translateX(20px) scale(0.9);
}
}
</style>
+264
View File
@@ -0,0 +1,264 @@
<template>
<div class="notification-container">
<TransitionGroup class="notification-list" name="notification-list" tag="div">
<div
v-for="notification in notifications"
:key="notification.id"
:class="{
success: notification.type === 'success',
error: notification.type === 'error',
info: notification.type === 'info'
}"
class="notification-item"
>
<div class="notification-icon">
<Icon v-if="notification.type === 'success'" :size="16" name="success" />
<Icon v-else-if="notification.type === 'error'" :size="16" name="error" />
<Icon v-else :size="16" name="info" />
</div>
<div class="notification-content">
{{ notification.message }}
</div>
<button class="notification-close" @click="removeToast(notification.id)">
<Icon :size="16" name="close" />
</button>
<!-- 进度条 -->
<div class="notification-progress">
<div
:style="{
animationDuration: `${notification.duration}ms`
}"
class="notification-progress-bar"
/>
</div>
</div>
</TransitionGroup>
</div>
</template>
<script setup>
import { onMounted, ref, watch } from 'vue'
import Icon from './Icon.vue'
import { useToast } from '~/composables/useToast'
// 使用 useToast 的共享状态
const { toasts, removeToast } = useToast()
const notifications = toasts
// 全局挂载通知函数 (兼容旧代码)
onMounted(() => {
// 兼容旧的全局调用方式
window.$showNotification = (message, type = 'info', autoClose = true, duration = 3000) => {
const { showToast } = useToast()
showToast(message, type, duration)
}
window.$clearNotifications = () => {
notifications.value = []
}
})
</script>
<style scoped>
.notification-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 99999;
pointer-events: none;
}
.notification-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.notification-item {
pointer-events: auto;
display: flex;
align-items: center;
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
background: #21242d;
color: #ffffff;
max-width: 400px;
min-width: 300px;
font-family: 'MiSans', sans-serif;
overflow: hidden;
position: relative;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.notification-item.success {
border-left: 4px solid #10b981;
}
.notification-item.error {
border-left: 4px solid #ef4444;
}
.notification-item.info {
border-left: 4px solid #0b5afe;
}
.notification-icon {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
margin-right: 16px;
font-weight: bold;
font-size: 16px;
flex-shrink: 0;
}
.success .notification-icon {
background: rgba(16, 185, 129, 0.2);
color: #10b981;
}
.error .notification-icon {
background: rgba(239, 68, 68, 0.2);
color: #ef4444;
}
.info .notification-icon {
background: rgba(11, 90, 254, 0.2);
color: #0b5afe;
}
.notification-content {
flex: 1;
font-size: 14px;
line-height: 1.4;
word-break: break-word;
}
.notification-close {
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 20px;
cursor: pointer;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 12px;
padding: 0;
transition: color 0.2s;
flex-shrink: 0;
}
.notification-close:hover {
color: #ffffff;
}
/* 进度条 */
.notification-progress {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background: rgba(255, 255, 255, 0.1);
}
.notification-progress-bar {
height: 100%;
width: 100%;
transform-origin: left center;
animation: progress-shrink linear forwards;
}
.success .notification-progress-bar {
background-color: #10b981;
}
.error .notification-progress-bar {
background-color: #ef4444;
}
.info .notification-progress-bar {
background-color: #0b5afe;
}
@keyframes progress-shrink {
0% {
transform: scaleX(1);
}
100% {
transform: scaleX(0);
}
}
/* 列表动画 */
.notification-list-enter-active {
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.notification-list-leave-active {
transition: all 0.3s cubic-bezier(0.55, 0.085, 0.68, 0.53);
}
.notification-list-enter-from {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
.notification-list-enter-to {
opacity: 1;
transform: translateX(0) scale(1);
}
.notification-list-leave-from {
opacity: 1;
transform: translateX(0) scale(1);
}
.notification-list-leave-to {
opacity: 0;
transform: translateX(100%) scale(0.9);
}
/* 移动动画 - 当通知被移除时,其他通知向上移动 */
.notification-list-move {
transition: transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* 响应式设计 */
@media (max-width: 768px) {
.notification-container {
top: 10px;
right: 10px;
left: 10px;
}
.notification-item {
max-width: none;
min-width: auto;
}
}
/* 悬停效果 */
.notification-item:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
transition: all 0.2s ease;
}
/* 暗色主题优化 */
@media (prefers-color-scheme: dark) {
.notification-item {
background: rgba(33, 36, 45, 0.95);
border-color: rgba(255, 255, 255, 0.15);
}
}
</style>
+133
View File
@@ -0,0 +1,133 @@
<template>
<Transition
:mode="transitionMode"
:name="transitionName"
@enter="onEnter"
@leave="onLeave"
@before-enter="onBeforeEnter"
@after-enter="onAfterEnter"
@before-leave="onBeforeLeave"
@after-leave="onAfterLeave"
>
<slot />
</Transition>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
// 过渡状态
const isTransitioning = ref(false)
const previousRoute = ref(null)
// 计算过渡名称
const transitionName = computed(() => {
const currentPath = route.path
const prevPath = previousRoute.value?.path
// 首页到后台的过渡
if (prevPath === '/' && currentPath === '/dashboard') {
return 'slide-left'
}
// 后台到首页的过渡
if (prevPath === '/dashboard' && currentPath === '/') {
return 'slide-right'
}
// 登录相关页面使用淡入淡出
if (currentPath.includes('/login') || currentPath.includes('/register')) {
return 'fade'
}
// 其他页面使用默认过渡
return 'page'
})
const transitionMode = computed(() => {
// 滑动过渡不需要mode,其他使用out-in
if (transitionName.value.includes('slide')) {
return undefined
}
return 'out-in'
})
// 过渡事件处理
const onBeforeEnter = (el) => {
isTransitioning.value = true
el.style.position = transitionName.value.includes('slide') ? 'absolute' : 'relative'
// 添加路由数据属性用于CSS选择器
el.setAttribute('data-route', route.name || 'unknown')
}
const onEnter = (el, done) => {
// 强制重排以确保动画生效
void el.offsetHeight
done()
}
const onAfterEnter = (el) => {
isTransitioning.value = false
el.style.position = 'relative'
// 清理数据属性
el.removeAttribute('data-route')
}
const onBeforeLeave = (el) => {
isTransitioning.value = true
el.style.position = transitionName.value.includes('slide') ? 'absolute' : 'relative'
// 添加路由数据属性
el.setAttribute('data-route', route.name || 'unknown')
}
const onLeave = (el, done) => {
done()
}
const onAfterLeave = (el) => {
isTransitioning.value = false
el.style.position = 'relative'
// 清理数据属性
el.removeAttribute('data-route')
}
// 监听路由变化
router.beforeEach((to, from) => {
previousRoute.value = from
})
// 暴露过渡状态
defineExpose({
isTransitioning
})
</script>
<style scoped>
/* 过渡容器样式 */
.transition-container {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
/* 确保滑动过渡时的容器样式 */
:deep(.slide-left-enter-active),
:deep(.slide-left-leave-active),
:deep(.slide-right-enter-active),
:deep(.slide-right-leave-active) {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
</style>
+92
View File
@@ -0,0 +1,92 @@
<template>
<div class="progress-container">
<div v-if="showPercentage || message" class="progress-info">
<span v-if="message" class="progress-message">{{ message }}</span>
<span v-if="showPercentage" class="progress-percentage">{{ percentage }}%</span>
</div>
<div class="progress-bar">
<div
:class="{ indeterminate: indeterminate }"
:style="{ width: `${percentage}%` }"
class="progress-fill"
/>
</div>
<div v-if="subMessage" class="progress-sub-message">{{ subMessage }}</div>
</div>
</template>
<script setup>
defineProps({
percentage: {
type: Number,
default: 0,
validator: (value) => value >= 0 && value <= 100
},
message: {
type: String,
default: ''
},
subMessage: {
type: String,
default: ''
},
showPercentage: {
type: Boolean,
default: true
},
indeterminate: {
type: Boolean,
default: false
}
})
</script>
<style scoped>
.progress-container {
margin: 15px 0;
width: 100%;
}
.progress-info {
display: flex;
justify-content: space-between;
margin-bottom: 5px;
font-size: 14px;
}
.progress-bar {
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background-color: #3490dc;
border-radius: 4px;
transition: width 0.3s ease;
}
.progress-fill.indeterminate {
width: 30% !important;
animation: progress-indeterminate 1.5s ease-in-out infinite;
background-image: linear-gradient(to right, #3490dc, #38c172, #3490dc);
background-size: 200% 100%;
}
.progress-sub-message {
margin-top: 5px;
font-size: 12px;
color: #6c757d;
}
@keyframes progress-indeterminate {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(300%);
}
}
</style>
+461
View File
@@ -0,0 +1,461 @@
<template>
<div class="user-tutorial-container relative">
<div class="tutorial-header flex justify-between items-start">
<div>
<h3 class="text-lg font-black text-zinc-100 flex items-center gap-2">
<BookOpen class="w-5 h-5 text-blue-400" />
使用指南
</h3>
<p class="text-xs text-zinc-500 mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
</div>
<button
class="start-tour-btn"
@click="startTour"
>
<Play class="w-4 h-4" />
开始交互式教程
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="tutorial-card">
<div class="card-icon bg-blue-500/10 text-blue-400">
<Search class="w-6 h-6" />
</div>
<h4 class="card-title">1. 搜索与投稿</h4>
<p class="card-desc">在“投稿歌曲”页面搜索您想听的音乐,选择合适的期望播出日期和序号,填写寄语后提交。</p>
</div>
<div class="tutorial-card">
<div class="card-icon bg-emerald-500/10 text-emerald-400">
<ThumbsUp class="w-6 h-6" />
</div>
<h4 class="card-title">2. 互动与点赞</h4>
<p class="card-desc">在“歌曲列表”中浏览大家提交的歌曲,为您喜欢的歌曲点赞,提高被选中的概率。</p>
</div>
<div class="tutorial-card">
<div class="card-icon bg-purple-500/10 text-purple-400">
<CalendarDays class="w-6 h-6" />
</div>
<h4 class="card-title">3. 关注排期与通知</h4>
<p class="card-desc">在“播出排期”中查看即将播出的节目单。您的歌曲被选中或播放时,会在“消息”中收到通知。</p>
</div>
</div>
<Teleport to="body" v-if="isTourActive">
<div class="tour-overlay"></div>
<div
class="tour-highlight-box"
:style="highlightStyle"
></div>
<div
class="tour-popover"
:style="popoverStyle"
>
<div class="tour-header">
<h4 class="tour-title">{{ currentStepData.title }}</h4>
<button class="tour-close-btn" @click="closeTour">&times;</button>
</div>
<div class="tour-body">
<p>{{ currentStepData.description }}</p>
</div>
<div class="tour-footer">
<span class="tour-progress">{{ currentStepIndex + 1 }} / {{ tourSteps.length }}</span>
<div class="tour-actions">
<button
v-if="currentStepIndex > 0"
class="tour-btn tour-btn-secondary"
@click="prevStep"
>上一步</button>
<button
class="tour-btn tour-btn-primary"
@click="nextStep"
>
{{ currentStepIndex === tourSteps.length - 1 ? '完成' : '下一步' }}
</button>
</div>
</div>
</div>
</Teleport>
</div>
</template>
<script setup>
import { onMounted, ref, computed, nextTick } from 'vue'
import { BookOpen, Search, ThumbsUp, CalendarDays, Play } from '@lucide/vue'
const isTourActive = ref(false)
const currentStepIndex = ref(0)
const highlightRect = ref({ top: 0, left: 0, width: 0, height: 0 })
const windowSize = ref({ width: 0, height: 0 })
const tourSteps = [
{
element: '.user-tutorial-container',
title: '欢迎来到 VoiceHub!',
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
position: 'top'
},
{
element: '.songs-tab',
title: '歌曲列表',
description: '在这里,您可以查看大家投稿的歌曲,并为喜欢的歌曲点赞。点赞数越高的歌曲,越容易被选中哦!',
position: 'bottom'
},
{
element: '.request-tab',
title: '投稿歌曲',
description: '想要点歌?点击这里搜索您想听的音乐,选择期望播出的日期和序号,填写您的寄语即可提交。',
position: 'bottom'
},
{
element: '.schedule-tab',
title: '播出排期',
description: '想知道您的歌曲什么时候播出?或者看看今天的节目单?都在这里。',
position: 'bottom'
}
]
const currentStepData = computed(() => tourSteps[currentStepIndex.value])
const isMobile = computed(() => windowSize.value.width > 0 && windowSize.value.width <= 640)
const setBodyScrollLocked = (locked) => {
if (!import.meta.client) return
document.body.style.overflow = locked ? 'hidden' : ''
}
const updateHighlightRect = () => {
if (!import.meta.client) return
const el = document.querySelector(currentStepData.value.element)
if (el) {
if (typeof el.scrollIntoView === 'function') {
try {
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
} catch {
window.scrollTo({
top: Math.max(0, window.scrollY + el.getBoundingClientRect().top - window.innerHeight / 2),
behavior: 'smooth'
})
}
}
const rect = el.getBoundingClientRect()
highlightRect.value = {
top: rect.top + window.scrollY,
left: rect.left + window.scrollX,
width: rect.width,
height: rect.height
}
setTimeout(() => {
const rect2 = el.getBoundingClientRect()
highlightRect.value = {
top: rect2.top + window.scrollY,
left: rect2.left + window.scrollX,
width: rect2.width,
height: rect2.height
}
}, 320)
}
}
const highlightStyle = computed(() => ({
top: `${highlightRect.value.top - 8}px`,
left: `${highlightRect.value.left - 8}px`,
width: `${highlightRect.value.width + 16}px`,
height: `${highlightRect.value.height + 16}px`,
}))
const popoverStyle = computed(() => {
if (isMobile.value) {
return {
top: 'calc(env(safe-area-inset-top, 0px) + 12px)',
left: '50%',
transform: 'translateX(-50%)',
position: 'fixed',
bottom: 'auto'
}
}
const rect = highlightRect.value
const pos = currentStepData.value.position
const spacing = 16
// 默认居中显示(如果没找到元素)
if (rect.width === 0) {
return {
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
position: 'fixed'
}
}
let top = 0
let left = 0
if (pos === 'bottom') {
top = rect.top + rect.height + spacing
left = rect.left + rect.width / 2
return {
top: `${top}px`,
left: `${left}px`,
transform: 'translateX(-50%)',
position: 'absolute'
}
} else {
// top position
top = rect.top - spacing
left = rect.left + rect.width / 2
return {
top: `${top}px`,
left: `${left}px`,
transform: 'translate(-50%, -100%)',
position: 'absolute'
}
}
})
const startTour = async () => {
if (!import.meta.client) return
isTourActive.value = true
currentStepIndex.value = 0
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
window.addEventListener('resize', handleResize)
setBodyScrollLocked(true)
await nextTick()
updateHighlightRect()
}
const closeTour = () => {
isTourActive.value = false
if (import.meta.client) {
window.removeEventListener('resize', handleResize)
}
setBodyScrollLocked(false)
}
const nextStep = async () => {
if (currentStepIndex.value < tourSteps.length - 1) {
currentStepIndex.value++
await nextTick()
updateHighlightRect()
} else {
closeTour()
}
}
const prevStep = async () => {
if (currentStepIndex.value > 0) {
currentStepIndex.value--
await nextTick()
updateHighlightRect()
}
}
const handleResize = () => {
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
updateHighlightRect()
}
onMounted(() => {
if (import.meta.client) {
windowSize.value = { width: window.innerWidth, height: window.innerHeight }
const hasSeenTour = localStorage.getItem('voicehub_has_seen_tour_v2')
if (!hasSeenTour) {
setTimeout(() => {
startTour()
localStorage.setItem('voicehub_has_seen_tour_v2', 'true')
}, 1500)
}
}
})
</script>
<style scoped>
.user-tutorial-container {
background: rgba(0, 0, 0, 0.4);
border-radius: 16px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
backdrop-filter: blur(10px);
}
.tutorial-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
}
.tutorial-card:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
transform: translateY(-2px);
}
.card-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1rem;
}
.card-title {
font-family: 'MiSans', sans-serif;
font-weight: 800;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
margin-bottom: 0.5rem;
}
.card-desc {
font-family: 'MiSans', sans-serif;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
line-height: 1.6;
}
.start-tour-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
background: rgba(59, 130, 246, 0.15);
color: #60a5fa;
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s ease;
cursor: pointer;
}
.start-tour-btn:hover {
background: rgba(59, 130, 246, 0.25);
transform: translateY(-1px);
}
/* 简易教程引导样式 */
.tour-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.75);
z-index: 9998;
}
.tour-highlight-box {
position: absolute;
z-index: 9999;
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 0, 0, 0.5);
border: 2px solid #3b82f6;
pointer-events: none;
transition: all 0.4s ease;
background: transparent;
}
.tour-popover {
background: #18181b;
border: 1px solid #27272a;
border-radius: 12px;
padding: 1.25rem;
width: 320px;
max-width: calc(100vw - 32px);
z-index: 10000;
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.5);
transition: all 0.4s ease;
}
.tour-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.75rem;
}
.tour-title {
font-size: 1.1rem;
font-weight: 800;
color: #f4f4f5;
margin: 0;
}
.tour-close-btn {
background: none;
border: none;
color: #71717a;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
padding: 0;
margin: -4px -4px 0 0;
}
.tour-close-btn:hover {
color: #f4f4f5;
}
.tour-body p {
color: #a1a1aa;
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 1rem 0;
}
.tour-footer {
display: flex;
justify-content: space-between;
align-items: center;
}
.tour-progress {
color: #71717a;
font-size: 0.8rem;
font-weight: 600;
}
.tour-actions {
display: flex;
gap: 0.5rem;
}
.tour-btn {
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.tour-btn-secondary {
background: #27272a;
color: #d4d4d8;
border: 1px solid #3f3f46;
}
.tour-btn-secondary:hover {
background: #3f3f46;
}
.tour-btn-primary {
background: #3b82f6;
color: white;
border: none;
}
.tour-btn-primary:hover {
background: #2563eb;
}
</style>
+140
View File
@@ -0,0 +1,140 @@
<template>
<div v-if="data.topArtist" class="slide-container">
<!-- Background Gradients -->
<div class="bg-overlay" />
<div class="animated-bg" />
<Transition name="entry" appear>
<div v-show="active" class="content">
<div class="label">特别偏爱</div>
<p class="text-intro">你的年度歌手是</p>
<div class="artist-container">
<div class="artist-bg-blur" />
<h2 class="artist-name">{{ data.topArtist }}</h2>
</div>
<p class="text-desc">Ta 的声音似乎特别吸引你,<br >陪伴了你无数个日夜。</p>
</div>
</Transition>
</div>
</template>
<script setup>
defineProps({
data: Object,
active: Boolean
})
</script>
<style scoped>
.slide-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #1a0b2e;
}
.bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, #000, transparent, transparent);
opacity: 0.8;
z-index: 10;
}
.animated-bg {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background: linear-gradient(to bottom, #581c87, #000);
opacity: 0.5;
animation: pulse 8s infinite;
}
.content {
z-index: 20;
width: 100%;
padding: 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.label {
color: #d8b4fe; /* purple-300 */
letter-spacing: 0.3em;
text-transform: uppercase;
margin-bottom: 1.5rem;
font-size: 0.875rem;
font-weight: 700;
border-bottom: 1px solid rgba(168, 85, 247, 0.3);
padding-bottom: 0.5rem;
}
.text-intro {
font-size: 1.5rem;
color: #d1d5db;
font-weight: 300;
margin-bottom: 3rem;
}
.artist-container {
position: relative;
width: 100%;
}
.artist-bg-blur {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 120%;
height: 120%;
background: rgba(168, 85, 247, 0.2);
filter: blur(60px);
border-radius: 50%;
}
.artist-name {
position: relative;
font-size: 5rem;
font-weight: 900;
font-style: italic;
letter-spacing: -0.05em;
background-image: linear-gradient(to bottom right, #e9d5ff, #f472b6, #fb923c);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));
word-break: break-word;
padding: 0 0.5rem;
}
.text-desc {
margin-top: 3rem;
color: #9ca3af;
max-width: 24rem;
margin-left: auto;
margin-right: auto;
line-height: 1.625;
font-size: 1rem;
}
@media (max-width: 768px) {
.artist-name {
font-size: 3rem;
}
.text-intro {
font-size: 1.25rem;
margin-bottom: 2rem;
}
}
</style>
@@ -0,0 +1,219 @@
<template>
<div v-if="data.firstSong" class="slide-container">
<!-- Background Image Blur -->
<div
v-if="data.firstSong.cover"
class="bg-blur"
:style="{ backgroundImage: `url(${data.firstSong.cover})` }"
/>
<Transition name="entry" appear>
<div v-show="active" class="content">
<span class="badge">初次相遇</span>
<p class="text-intro">这一年,你点的<span class="highlight">第一首歌</span>是</p>
<div class="vinyl-container">
<div class="vinyl-record">
<img
v-if="data.firstSong.cover"
:src="data.firstSong.cover"
alt="Cover"
class="cover-img"
>
<div v-else class="placeholder-icon">
<svg
width="64"
height="64"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</div>
<div class="glossy-overlay" />
</div>
</div>
<div class="song-info">
<h2 class="song-title">{{ data.firstSong.title }}</h2>
<p class="song-artist">{{ data.firstSong.artist }}</p>
</div>
<p class="date-text">{{ formatDate(data.firstSong.createdAt) }}</p>
</div>
</Transition>
</div>
</template>
<script setup>
defineProps({
data: Object,
active: Boolean
})
const formatDate = (dateStr) => {
const date = new Date(dateStr)
return `${date.getMonth() + 1}月${date.getDate()}日`
}
</script>
<style scoped>
.slide-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #000;
}
.bg-blur {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
opacity: 0.3;
filter: blur(80px);
transform: scale(1.1);
}
.content {
z-index: 10;
width: 100%;
max-width: 450px;
padding: 2rem;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.badge {
display: inline-block;
padding: 0.25rem 1rem;
border-radius: 9999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(12px);
font-size: 0.875rem;
margin-bottom: 1.5rem;
letter-spacing: 0.1em;
color: #f472b6; /* brand-pink */
font-weight: 600;
}
.text-intro {
font-size: 1.5rem;
color: #d1d5db;
font-weight: 300;
margin-bottom: 2rem;
}
.highlight {
color: #fff;
font-weight: 700;
margin: 0 0.5rem;
}
.vinyl-container {
position: relative;
width: 100%;
max-width: 320px;
aspect-ratio: 1;
margin-bottom: 2rem;
}
.vinyl-record {
position: relative;
width: 100%;
height: 100%;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
background: #111827;
display: flex;
align-items: center;
justify-content: center;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform 0.7s;
}
.vinyl-record:hover .cover-img {
transform: scale(1.05);
}
.placeholder-icon {
color: #4b5563;
}
.glossy-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top right, rgba(255, 255, 255, 0.1), transparent);
pointer-events: none;
}
.song-info {
width: 100%;
margin-bottom: 0.5rem;
}
.song-title {
font-size: 1.875rem;
font-weight: 700;
margin-bottom: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
}
.song-artist {
font-size: 1.125rem;
color: #9ca3af;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.date-text {
margin-top: 2rem;
font-size: 1.125rem;
color: #9ca3af;
font-family:
'MiSans',
system-ui,
-apple-system,
sans-serif;
font-weight: 500;
letter-spacing: 0.05em;
}
@media (max-width: 768px) {
.text-intro {
font-size: 1.125rem;
}
.song-title {
font-size: 1.5rem;
}
.vinyl-container {
max-width: 280px;
}
}
</style>
+113
View File
@@ -0,0 +1,113 @@
<template>
<div class="slide-container intro-bg">
<!-- Dynamic Background Elements -->
<div class="blob blob-1" />
<div class="blob blob-2" />
<Transition name="entry" appear>
<div v-show="active" class="content">
<div class="year-bg">{{ data.year }}</div>
<div class="sub-title">年度点歌报告</div>
<h1 class="main-title">VoiceHub</h1>
</div>
</Transition>
</div>
</template>
<script setup>
defineProps({
data: Object,
active: Boolean
})
</script>
<style scoped>
.slide-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
overflow: hidden;
background: radial-gradient(circle at center, #111827, #000000);
}
.blob {
position: absolute;
border-radius: 50%;
filter: blur(100px);
opacity: 0.3;
}
.blob-1 {
top: -20%;
left: -10%;
width: 500px;
height: 500px;
background: rgba(139, 92, 246, 0.4); /* brand-purple */
animation: spin 20s linear infinite;
}
.blob-2 {
bottom: -10%;
right: -10%;
width: 600px;
height: 600px;
background: rgba(244, 114, 182, 0.2); /* brand-pink */
animation: reverse-spin 25s linear infinite;
}
.content {
z-index: 10;
text-align: center;
padding: 1.5rem;
position: relative;
}
.year-bg {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 14rem;
font-weight: 900;
color: rgba(255, 255, 255, 0.03);
pointer-events: none;
user-select: none;
line-height: 1;
}
.sub-title {
font-size: 1.5rem;
font-weight: 300;
color: #9ca3af;
margin-bottom: 1rem;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.main-title {
font-size: 5rem;
font-weight: 900;
background-image: linear-gradient(to right, #ffffff, #e5e7eb, #6b7280);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin-bottom: 2rem;
filter: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));
}
@media (max-width: 768px) {
.year-bg {
font-size: 8rem;
}
.main-title {
font-size: 3rem;
}
.sub-title {
font-size: 1rem;
}
}
</style>
+151
View File
@@ -0,0 +1,151 @@
<template>
<div class="slide-container">
<div class="bg-gradient" />
<Transition name="entry" appear>
<div v-show="active" class="content">
<div v-if="data.topPlatform" class="card platform-card">
<span class="label">常用平台</span>
<span class="platform-value">{{ formatPlatform(data.topPlatform) }}</span>
</div>
<div class="grid-container">
<div v-if="data.activeMonth" class="card active-card">
<span class="label">最活跃</span>
<span class="active-value">{{ data.activeMonth }}<span class="unit">月</span></span>
</div>
<div class="card votes-card">
<span class="label">投出票数</span>
<span class="votes-value">{{ data.totalVotes }}</span>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
defineProps({
data: Object,
active: Boolean
})
const formatPlatform = (platform) => {
const map = {
netease: '网易云音乐',
bilibili: '哔哩哔哩',
qq: 'QQ音乐',
kugou: '酷狗音乐',
kuwo: '酷我音乐',
migu: '咪咕音乐',
tencent: 'QQ音乐'
}
return map[platform] || platform
}
</script>
<style scoped>
.slide-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #111827; /* gray-900 */
}
.bg-gradient {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 50%;
background: linear-gradient(to bottom, #1f2937, transparent);
opacity: 0.2;
}
.content {
z-index: 10;
width: 100%;
max-width: 28rem;
padding: 1.5rem;
display: grid;
gap: 1.5rem;
}
.card {
padding: 1.5rem;
border-radius: 1.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.5rem; /* adjusted */
}
.platform-value {
font-size: 1.5rem; /* text-2xl */
font-weight: 700;
background-image: linear-gradient(to right, #f87171, #fb923c);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.grid-container {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.active-card,
.votes-card {
aspect-ratio: 1;
}
.active-value {
font-size: 2.25rem; /* text-4xl */
font-weight: 700;
color: #fde047; /* yellow-300 */
}
.unit {
font-size: 1rem;
margin-left: 0.25rem;
color: #9ca3af;
}
.votes-value {
font-size: 1.875rem; /* text-3xl */
font-weight: 700;
color: #67e8f9; /* cyan-300 */
}
@media (min-width: 768px) {
.platform-value {
font-size: 1.875rem;
}
.active-value {
font-size: 3rem;
}
.votes-value {
font-size: 2.25rem;
}
.card {
padding: 1.5rem;
}
}
</style>
+210
View File
@@ -0,0 +1,210 @@
<template>
<div class="slide-container">
<div class="bg-gradient" />
<Transition name="entry" appear>
<div v-show="active" class="content">
<div class="label">未来可期</div>
<div class="year-container">
<h2 class="year-next">{{ data.year + 1 }}</h2>
<div class="year-underline" />
</div>
<p class="text-intro">愿音乐继续陪伴你的每一天</p>
<div class="action-buttons">
<NuxtLink to="/" class="btn-primary group">
<div class="btn-bg" />
<span class="btn-content">
<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="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
<polyline points="9 22 9 12 15 12 15 22" />
</svg>
返回首页
</span>
</NuxtLink>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
defineProps({
data: Object,
active: Boolean
})
defineEmits(['share'])
</script>
<style scoped>
.slide-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #000;
}
.bg-gradient {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, #1f2937, #000, #000);
opacity: 0.5;
}
.content {
z-index: 10;
text-align: center;
padding: 1.5rem;
width: 100%;
max-width: 32rem;
}
.label {
color: #6b7280;
font-size: 0.875rem;
letter-spacing: 0.5em;
text-transform: uppercase;
margin-bottom: 2rem;
}
.year-container {
position: relative;
margin-bottom: 3rem;
display: inline-block;
}
.year-next {
font-size: 6rem;
font-weight: 900;
line-height: 1;
background-image: linear-gradient(to bottom, #fff, #1f2937);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.year-underline {
position: absolute;
bottom: -1rem;
left: 0;
width: 100%;
height: 4px;
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent);
}
.text-intro {
font-size: 1.125rem;
color: #9ca3af;
margin-bottom: 3rem;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 1rem;
align-items: center;
justify-content: center;
}
@media (min-width: 640px) {
.action-buttons {
flex-direction: row;
}
}
.btn-primary {
position: relative;
width: 100%;
padding: 1rem 2rem;
border-radius: 9999px;
background: #fff;
color: #000;
font-weight: 700;
font-size: 1.125rem;
overflow: hidden;
display: flex;
justify-content: center;
text-decoration: none;
transition: transform 0.1s;
}
.btn-primary:active {
transform: scale(0.95);
}
.btn-bg {
position: absolute;
inset: 0;
background: #e5e7eb;
transform: translateY(100%);
transition: transform 0.3s;
}
.btn-primary:hover .btn-bg {
transform: translateY(0);
}
.btn-content {
position: relative;
z-index: 10;
display: flex;
align-items: center;
gap: 0.5rem;
}
.btn-secondary {
width: 100%;
padding: 1rem 2rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
font-weight: 700;
font-size: 1.125rem;
backdrop-filter: blur(12px);
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.2);
}
.btn-secondary:active {
transform: scale(0.95);
}
@media (min-width: 640px) {
.btn-primary,
.btn-secondary {
width: auto;
}
}
@media (max-width: 768px) {
.year-next {
font-size: 4rem;
}
.text-intro {
font-size: 1rem;
}
}
</style>
+217
View File
@@ -0,0 +1,217 @@
<template>
<div ref="shareCardRef" class="share-card">
<div class="card-background">
<div class="gradient-overlay" />
<div class="noise-overlay" />
</div>
<div class="card-content">
<div class="header">
<div class="logo">VoiceHub</div>
<div class="year">{{ data.year }}</div>
</div>
<div class="title-section">
<h1 class="main-title">年度点歌报告</h1>
<p class="subtitle">Music Journey</p>
</div>
<div class="stats-grid">
<div class="stat-item">
<div class="stat-value">{{ data.totalRequests }}</div>
<div class="stat-label">点歌次数</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ data.playedRequests }}</div>
<div class="stat-label">已播放</div>
</div>
<div class="stat-item">
<div class="stat-value">{{ data.totalVotes }}</div>
<div class="stat-label">投票数</div>
</div>
</div>
<div class="footer">
<div class="footer-text">用音乐记录每一刻</div>
<div class="footer-line" />
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
defineProps({
data: {
type: Object,
required: true
}
})
const shareCardRef = ref(null)
defineExpose({
shareCardRef
})
</script>
<style scoped>
.share-card {
position: relative;
width: 100%;
height: 100%;
min-height: 600px;
overflow: hidden;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
}
.card-background {
position: absolute;
inset: 0;
}
.gradient-overlay {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 70%, rgba(244, 114, 182, 0.15) 0%, transparent 50%),
radial-gradient(circle at 50% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 60%);
}
.noise-overlay {
position: absolute;
inset: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
.card-content {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
justify-content: space-between;
height: 100%;
padding: 3rem;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
}
.logo {
font-size: 1.5rem;
font-weight: 900;
background: linear-gradient(135deg, #8b5cf6, #ec4899);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.year {
font-size: 3rem;
font-weight: 900;
color: rgba(255, 255, 255, 0.1);
line-height: 1;
}
.title-section {
text-align: center;
margin: 2rem 0;
}
.main-title {
font-size: 3.5rem;
font-weight: 900;
background: linear-gradient(135deg, #ffffff 0%, #e5e7eb 50%, #9ca3af 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin-bottom: 0.5rem;
letter-spacing: -0.02em;
}
.subtitle {
font-size: 1.25rem;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.3em;
text-transform: uppercase;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 2rem;
margin: 2rem 0;
}
.stat-item {
text-align: center;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.03);
border-radius: 1rem;
border: 1px solid rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
}
.stat-value {
font-size: 2.5rem;
font-weight: 900;
background: linear-gradient(135deg, #60a5fa, #3b82f6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
margin-bottom: 0.5rem;
}
.stat-label {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.5);
letter-spacing: 0.05em;
}
.footer {
text-align: center;
padding-top: 2rem;
}
.footer-text {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.4);
letter-spacing: 0.1em;
margin-bottom: 1rem;
}
.footer-line {
width: 100px;
height: 2px;
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
margin: 0 auto;
}
@media (max-width: 768px) {
.card-content {
padding: 2rem;
}
.main-title {
font-size: 2.5rem;
}
.stats-grid {
gap: 1rem;
}
.stat-value {
font-size: 1.75rem;
}
.year {
font-size: 2rem;
}
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<div class="slide-container">
<div class="bg-gradient" />
<Transition name="entry" appear>
<div v-show="active" class="content">
<div class="label">音乐足迹</div>
<p class="text-intro">这一年,你一共发起了</p>
<div class="number-container">
<span class="big-number">{{ data.totalRequests.toLocaleString() }}</span>
<span class="suffix">次点歌</span>
</div>
<div class="sub-stats">
<div v-if="data.playedRequests > 0" class="sub-content">
<p class="text-base">
其中,<span class="highlight-green">{{ data.playedRequests }}</span> 首
</p>
<p class="text-sub">在广播站被播出,传递给了更多人</p>
</div>
<p v-else class="text-sub">期待你的歌曲早日被播出</p>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
defineProps({
data: Object,
active: Boolean
})
</script>
<style scoped>
.slide-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #030712; /* gray-950 */
}
.bg-gradient {
position: absolute;
inset: 0;
background: radial-gradient(
ellipse at top right,
rgba(30, 58, 138, 0.4),
transparent,
transparent
);
}
.content {
z-index: 10;
max-width: 42rem;
padding: 1.5rem;
text-align: center;
}
.label {
margin-bottom: 1rem;
color: #3b82f6; /* brand-blue */
font-weight: 700;
letter-spacing: 0.05em;
font-size: 1.125rem;
}
.text-intro {
font-size: 1.5rem;
color: #9ca3af;
margin-bottom: 0.5rem;
}
.number-container {
position: relative;
padding: 1rem 0;
display: flex;
align-items: baseline;
justify-content: center;
gap: 0.5rem;
}
.big-number {
font-size: 10rem;
font-weight: 900;
line-height: 1;
background-image: linear-gradient(to bottom, #60a5fa, #1d4ed8);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
filter: drop-shadow(0 0 30px rgba(37, 99, 235, 0.5));
}
.suffix {
font-size: 1.5rem;
color: #6b7280;
font-weight: 500;
margin-left: 0.5rem;
}
.sub-stats {
margin-top: 3rem;
padding: 1.5rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
backdrop-filter: blur(4px);
}
.sub-content {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.text-base {
font-size: 1.125rem;
color: #d1d5db;
}
.highlight-green {
font-size: 1.875rem;
font-weight: 700;
color: #4ade80;
margin: 0 0.25rem;
}
.text-sub {
font-size: 1rem;
color: #9ca3af;
}
@media (max-width: 768px) {
.number-container {
flex-direction: column;
align-items: center;
gap: 0;
}
.big-number {
font-size: 7rem;
}
.text-intro {
font-size: 1.25rem;
}
.suffix {
font-size: 1.25rem;
margin-top: 0.5rem;
margin-left: 0;
}
}
</style>