初次提交
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
+184
View File
@@ -0,0 +1,184 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-200 pb-24">
<!-- 顶部导航栏 -->
<div
class="sticky top-0 z-30 bg-zinc-950/80 backdrop-blur-xl border-b border-zinc-900/50 px-4 py-4 mb-8"
>
<div class="max-w-[1200px] mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<button
class="p-2 hover:bg-zinc-900 rounded-xl transition-all text-zinc-400 hover:text-zinc-100"
@click="goBack"
>
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-xl font-black text-zinc-100 tracking-tight">账号管理</h1>
<p class="text-[10px] text-zinc-500 font-medium uppercase tracking-widest mt-0.5">
Account Management
</p>
</div>
</div>
</div>
</div>
<div class="max-w-[1200px] mx-auto px-4">
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
<!-- 左侧:用户信息概览 (PC端占据 4/12) -->
<div class="lg:col-span-4 space-y-6">
<section :class="sectionClass" class="flex flex-col items-center text-center">
<div class="relative group">
<div
class="w-32 h-32 rounded-full overflow-hidden bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center text-white text-4xl font-black shadow-2xl shadow-blue-900/20 mb-6 group-hover:scale-105 transition-transform duration-500"
>
<img
v-if="auth.user.value?.avatar && !avatarError"
:src="auth.user.value.avatar"
class="w-full h-full object-cover"
@error="avatarError = true"
>
<span v-else>{{ userInitials }}</span>
</div>
<div
class="absolute -bottom-1 -right-1 p-2 bg-zinc-900 border border-zinc-800 rounded-full text-blue-500 shadow-xl"
>
<User :size="16" />
</div>
</div>
<div class="space-y-2">
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">
{{ auth.user.value?.name || auth.user.value?.username }}
</h2>
<p class="text-sm font-medium text-zinc-500">@{{ auth.user.value?.username }}</p>
</div>
<div class="flex flex-wrap justify-center gap-2 mt-6">
<span
class="px-3 py-1 bg-blue-500/10 border border-blue-500/20 text-blue-500 text-[10px] font-black uppercase tracking-wider rounded-full"
>
{{ roleName }}
</span>
<span
v-if="auth.user.value?.grade"
class="px-3 py-1 bg-zinc-800 text-zinc-400 text-[10px] font-black uppercase tracking-wider rounded-full"
>
{{ auth.user.value?.grade }}
</span>
<span
v-if="auth.user.value?.class"
class="px-3 py-1 bg-zinc-800 text-zinc-400 text-[10px] font-black uppercase tracking-wider rounded-full"
>
{{ auth.user.value?.class }}
</span>
</div>
</section>
</div>
<!-- 右侧:详细设置 (PC端占据 8/12) -->
<div class="lg:col-span-8 space-y-8">
<!-- 个人信息设置 -->
<AuthProfileForm />
<!-- 第三方登录绑定 -->
<section v-if="hasOAuthProviders" :class="sectionClass">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="p-2.5 bg-purple-500/10 rounded-xl">
<LinkIcon :size="20" class="text-purple-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>
<AuthOAuthBindingCard />
</section>
<!-- 修改密码 -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl">
<Lock :size="20" class="text-blue-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>
<div class="max-w-md">
<AuthChangePasswordForm />
</div>
</section>
<!-- 双重认证 -->
<section :class="sectionClass">
<AuthTwoFactorSetup :initial-enabled="auth.user.value?.has2FA" />
</section>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { computed, onMounted } from 'vue'
import { ArrowLeft, User, Link as LinkIcon, Lock } from '@lucide/vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
const auth = useAuth()
const router = useRouter()
const route = useRoute()
const { showToast } = useToast()
const config = useRuntimeConfig()
const hasOAuthProviders = computed(() => {
return config.public.oauth.github || config.public.oauth.casdoor || config.public.oauth.google
})
const avatarError = ref(false)
// 监听用户头像变化,重置错误状态
watch(
() => auth.user.value?.avatar,
() => {
avatarError.value = false
}
)
// 处理来自 OAuth 回调的消息
onMounted(() => {
if (route.query.message) {
showToast(route.query.message, 'success')
router.replace({ query: { ...route.query, message: undefined, error: undefined } })
}
if (route.query.error) {
showToast(route.query.error, 'error')
router.replace({ query: { ...route.query, message: undefined, error: undefined } })
}
})
// 样式类常量
const sectionClass = 'bg-zinc-900/40 border border-zinc-900 rounded-3xl p-6 md:p-8 shadow-2xl'
const userInitials = computed(() => {
const name = auth.user.value?.name || auth.user.value?.username || 'U'
return name.charAt(0).toUpperCase()
})
const roleName = computed(() => {
const role = auth.user.value?.role
const map = {
ADMIN: '管理员',
SUPER_ADMIN: '超级管理员',
SONG_ADMIN: '审歌员',
TEACHER: '教师',
USER: '普通用户'
}
return map[role] || role
})
const goBack = () => {
navigateTo('/')
}
</script>
+85
View File
@@ -0,0 +1,85 @@
<template>
<div class="min-h-screen bg-zinc-950 flex items-center justify-center p-6">
<div class="max-w-md w-full">
<div
class="bg-zinc-900/40 border border-zinc-900 rounded-3xl p-8 md:p-10 shadow-2xl text-center space-y-8"
>
<!-- 错误图标 -->
<div class="flex justify-center">
<div class="relative">
<div
class="w-24 h-24 rounded-3xl bg-rose-500/10 border border-rose-500/20 flex items-center justify-center text-rose-500"
>
<ShieldAlert :size="48" stroke-width="1.5" />
</div>
<div
class="absolute -top-2 -right-2 w-8 h-8 bg-zinc-950 border border-zinc-800 rounded-full flex items-center justify-center text-rose-500 shadow-xl"
>
<X :size="16" stroke-width="3" />
</div>
</div>
</div>
<!-- 错误标题与信息 -->
<div class="space-y-3">
<h1 class="text-2xl font-black text-zinc-100 tracking-tight">身份验证失败</h1>
<p class="text-sm text-zinc-500 leading-relaxed">{{ errorMessage }}</p>
</div>
<!-- 错误详情 -->
<div
v-if="errorCode"
class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-2"
>
<p class="text-[10px] text-zinc-600 font-black uppercase tracking-widest">错误代码</p>
<code
class="text-xs font-mono text-blue-500 font-bold bg-blue-500/5 px-2 py-1 rounded-lg"
>
{{ errorCode }}
</code>
</div>
<!-- 操作按钮 -->
<div class="grid grid-cols-1 gap-3 pt-4">
<NuxtLink
to="/login"
class="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-95"
>
<LogIn :size="18" />
重新尝试登录
</NuxtLink>
<NuxtLink
to="/"
class="flex items-center justify-center gap-2 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm font-black rounded-xl transition-all active:scale-95"
>
<Home :size="18" />
返回首页
</NuxtLink>
</div>
<!-- 底部提示 -->
<p class="text-[10px] text-zinc-700 font-medium uppercase tracking-widest pt-4">
如果您认为这是一个错误,请联系系统管理员
</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { ShieldAlert, X, LogIn, Home } from '@lucide/vue'
const route = useRoute()
const errorCode = computed(() => route.query.code)
const errorMessage = computed(
() => route.query.message || '在尝试使用第三方账号登录时发生未知错误,请重试'
)
definePageMeta({
layout: 'default'
})
</script>
<style scoped></style>
+372
View File
@@ -0,0 +1,372 @@
<template>
<div class="auth-layout">
<div class="auth-container">
<!-- 左侧信息区域 -->
<div class="info-section">
<div class="info-content">
<div class="logo-section">
<img
alt="VoiceHub Logo"
class="brand-logo"
:src="logo"
width="160"
height="160"
decoding="async"
fetchpriority="high"
>
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle || 'VoiceHub' }}</h1>
</div>
<div v-if="isFirstLogin" class="welcome-message">
<h2>欢迎使用VoiceHub</h2>
<p>为了保障您的账号安全,请设置一个新的密码</p>
</div>
<div v-else class="security-message">
<h2>账号安全</h2>
<p>定期更新密码有助于保护您的账号安全</p>
</div>
<div class="security-tips">
<h3>密码安全建议</h3>
<div class="tip-list">
<div class="tip-item">
<svg
class="tip-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<polyline points="20,6 9,17 4,12" />
</svg>
<span>至少8个字符</span>
</div>
<div class="tip-item">
<svg
class="tip-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<polyline points="20,6 9,17 4,12" />
</svg>
<span>包含大小写字母</span>
</div>
<div class="tip-item">
<svg
class="tip-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<polyline points="20,6 9,17 4,12" />
</svg>
<span>包含数字和特殊字符</span>
</div>
</div>
</div>
</div>
</div>
<!-- 右侧表单区域 -->
<div class="form-section">
<div class="form-container">
<div class="form-header">
<h2>{{ isFirstLogin ? '设置新密码' : '修改密码' }}</h2>
<p>{{ isFirstLogin ? '请设置一个安全的密码' : '更新您的登录密码' }}</p>
</div>
<ClientOnly>
<ChangePasswordForm :is-first-login="isFirstLogin" />
</ClientOnly>
<div class="form-footer">
<NuxtLink class="back-link" to="/">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<polyline points="15,18 9,12 15,6" />
</svg>
返回主页
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import ChangePasswordForm from '~/components/Auth/ChangePasswordForm.vue'
import { ref } from 'vue'
import logo from '~~/public/images/logo.png'
// 使用站点配置
const { siteTitle, initSiteConfig } = useSiteConfig()
const auth = useAuth()
const router = useRouter()
const isFirstLogin = ref(false)
// 未登录用户重定向到登录页
onMounted(async () => {
// 初始化站点配置
await initSiteConfig()
if (!auth.isAuthenticated.value && import.meta.client) {
router.push('/login')
return
}
// 检查是否需要修改密码(用于显示不同的UI提示)
if (import.meta.client) {
const userJson = localStorage.getItem('user')
if (userJson) {
const user = JSON.parse(userJson)
isFirstLogin.value = user.forcePasswordChange === true || !user.passwordChangedAt
}
}
})
</script>
<style scoped>
.auth-layout {
min-height: 100vh;
background: #0a0a0a;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.auth-container {
width: 100%;
max-width: 1200px;
background: #111111;
border-radius: 24px;
border: 1px solid #1f1f1f;
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 600px;
}
.info-section {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.info-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.info-content {
position: relative;
z-index: 1;
color: white;
text-align: center;
}
.logo-section {
margin-bottom: 40px;
}
.brand-logo {
width: 160px;
height: auto;
margin-bottom: 24px;
object-fit: contain;
}
.brand-title {
font-size: 36px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.welcome-message,
.security-message {
margin-bottom: 40px;
}
.welcome-message h2,
.security-message h2 {
font-size: 24px;
font-weight: 600;
margin: 0 0 12px 0;
color: white;
}
.welcome-message p,
.security-message p {
font-size: 16px;
color: rgba(255, 255, 255, 0.9);
margin: 0;
line-height: 1.6;
}
.security-tips {
text-align: left;
}
.security-tips h3 {
font-size: 18px;
font-weight: 600;
color: white;
margin: 0 0 20px 0;
}
.tip-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.tip-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.tip-icon {
width: 16px;
height: 16px;
color: #10b981;
flex-shrink: 0;
}
.tip-item span {
font-size: 14px;
color: white;
}
.form-section {
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
background: #111111;
}
.form-container {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column;
align-items: center;
}
.form-header {
text-align: center;
margin-bottom: 32px;
}
.form-header h2 {
font-size: 28px;
font-weight: 700;
color: #ffffff;
margin: 0 0 8px 0;
}
.form-header p {
font-size: 16px;
color: #888888;
margin: 0;
}
.form-footer {
margin-top: 32px;
text-align: center;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 8px;
color: #cccccc;
text-decoration: none;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
}
.back-link:hover {
background: #2a2a2a;
color: #ffffff;
border-color: #3a3a3a;
}
.back-link svg {
width: 16px;
height: 16px;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.auth-container {
grid-template-columns: 1fr;
max-width: 500px;
}
.info-section {
padding: 40px 30px;
}
.form-section {
padding: 40px 30px;
}
.brand-title {
font-size: 28px;
}
}
@media (max-width: 768px) {
.auth-layout {
padding: 10px;
}
.auth-container {
border-radius: 16px;
min-height: auto;
}
.info-section,
.form-section {
padding: 30px 20px;
}
.tip-list {
gap: 8px;
}
.tip-item {
padding: 8px 12px;
}
}
</style>
+73
View File
@@ -0,0 +1,73 @@
<template>
<div class="min-h-screen bg-[#0a0a0a] text-zinc-100">
<div class="max-w-4xl mx-auto px-4 py-10 space-y-8">
<div class="flex items-end justify-between gap-4">
<div>
<h1 class="text-3xl font-black tracking-tight">更新日志</h1>
<p class="text-sm text-zinc-500 mt-2">记录每次更新的内容与说明</p>
</div>
<NuxtLink to="/" class="text-sm text-blue-400 hover:text-blue-300 font-semibold">
返回首页
</NuxtLink>
</div>
<div v-if="loading" class="py-10 text-center text-zinc-500">加载中...</div>
<div v-else-if="entries.length === 0" class="py-10 text-center text-zinc-500">
暂无已发布更新日志
</div>
<div v-else class="space-y-5">
<section
v-for="item in entries"
:key="item.id"
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6"
>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div class="flex items-center gap-3">
<span class="px-2.5 py-1 rounded-lg bg-blue-500/10 text-blue-300 text-xs font-black">
v{{ item.version }}
</span>
<h2 class="text-lg font-black text-zinc-100">{{ item.title }}</h2>
</div>
<p class="text-xs text-zinc-500 font-semibold">
{{ formatDate(item.publishedAt || item.createdAt) }}
</p>
</div>
<pre class="mt-4 whitespace-pre-wrap text-sm text-zinc-300 leading-relaxed">{{
item.content
}}</pre>
</section>
</div>
</div>
<SiteFooter />
</div>
</template>
<script setup>
import { onMounted, ref } from 'vue'
const loading = ref(true)
const entries = ref([])
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/changelog', { query: { t: Date.now() } })
entries.value = Array.isArray(data) ? data : []
} finally {
loading.value = false
}
}
onMounted(load)
</script>
+586
View File
@@ -0,0 +1,586 @@
<template>
<div>
<ClientOnly>
<div class="admin-layout" @touchend="handleTouchEnd" @touchstart="handleTouchStart">
<!-- 左侧导航栏 -->
<AdminSidebar
:is-open="sidebarOpen"
:active-tab="activeTab"
:current-user="currentUser"
:permissions="permissions"
:site-title="siteTitle"
@navigate="handleNavigate"
@close="closeSidebar"
@logout="handleLogout"
/>
<!-- 移动端侧边栏遮罩 -->
<div
v-if="sidebarOpen"
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300"
@click="closeSidebar"
/>
<!-- 主内容区域 -->
<main
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-[#09090b] text-zinc-100"
>
<header
class="admin-main-header h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-zinc-800 bg-zinc-950/60 backdrop-blur-xl z-30"
>
<div class="flex items-center gap-3">
<button
class="admin-mobile-menu-btn lg:hidden p-2 text-zinc-400 hover:bg-zinc-800 rounded-lg transition-colors"
@click="toggleSidebar"
>
<Menu :size="20" />
</button>
<h1 class="text-xl font-bold tracking-tight">{{ getPageTitle() }}</h1>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800 transition-colors"
@click="openOnboardingTutorial"
>
新手引导
</button>
</div>
</header>
<div
ref="contentScrollRef"
class="admin-content-area flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
>
<!-- 移动端返回顶部按钮 -->
<button
v-if="showBackToTop"
aria-label="返回顶部"
class="back-to-top-btn fixed bottom-8 right-8 p-3 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-all z-50"
@click="scrollToTop"
>
<ChevronUp :size="24" />
</button>
<div :key="`${activeTab}-${tabRefreshKey}`">
<!-- 数据概览 -->
<div
v-if="activeTab === 'overview' && permissions.canAccessPage('overview')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminOverviewDashboard @navigate="handleNavigate" />
</div>
<!-- 工作流教程 -->
<div
v-if="activeTab === 'workflow-tutorial' && permissions.canAccessPage('workflow-tutorial')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminWorkflowTutorial />
</div>
<!-- 歌曲管理 -->
<div
v-if="activeTab === 'songs' && permissions.canAccessPage('songs')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSongManagement />
</div>
<!-- 排期管理 -->
<div
v-if="activeTab === 'schedule' && permissions.canAccessPage('schedule')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminScheduleManager />
</div>
<!-- 打印排期 -->
<div
v-if="activeTab === 'print' && permissions.canAccessPage('print')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminSchedulePrinter />
</div>
<!-- 歌单卡片图片 -->
<div
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('print')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminPlaylistCardPage />
</div>
<!-- 数据分析 -->
<div
v-if="activeTab === 'data-analysis' && permissions.canAccessPage('data-analysis')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminDataAnalysisPanel />
</div>
<!-- 用户管理 -->
<div
v-if="activeTab === 'users' && permissions.canAccessPage('users')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminUserManager />
</div>
<!-- 消息管理 -->
<div
v-if="activeTab === 'notifications' && permissions.canAccessPage('notifications')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminNotificationSender />
</div>
<!-- SMTP邮件配置 -->
<div
v-if="activeTab === 'smtp-config' && permissions.canAccessPage('smtp-config')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSmtpManager />
</div>
<!-- 播出时段 -->
<div
v-if="activeTab === 'playtimes' && permissions.canAccessPage('playtimes')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminPlayTimeManager />
</div>
<!-- 投稿管理 -->
<div
v-if="activeTab === 'request-times' && permissions.canAccessPage('request-times')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminRequestTimeManager />
</div>
<!-- 学期管理 -->
<div
v-if="activeTab === 'semesters' && permissions.canAccessPage('semesters')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSemesterManager />
</div>
<!-- 黑名单管理 -->
<div
v-if="activeTab === 'blacklist' && permissions.canAccessPage('blacklist')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminBlacklistManager />
</div>
<!-- 站点配置 -->
<div
v-if="activeTab === 'site-config' && permissions.canAccessPage('site-config')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminSiteConfigManager />
</div>
<!-- 更新日志 -->
<div
v-if="activeTab === 'changelog' && permissions.canAccessPage('changelog')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminChangelogManager />
</div>
<!-- 选曲日期管理 -->
<div
v-if="activeTab === 'expected-play-dates' && permissions.canAccessPage('settings')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminExpectedPlayDates />
</div>
<!-- 数据库操作 -->
<div
v-if="activeTab === 'database' && permissions.canAccessPage('database')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminDatabaseManager />
</div>
<!-- 权限组管理 -->
<div
v-if="activeTab === 'roles' && permissions.canAccessPage('roles')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminRolesManager />
</div>
<!-- API密钥管理 -->
<div
v-if="activeTab === 'api-keys' && permissions.canAccessPage('api-keys')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<LazyAdminApiKeyManager />
</div>
</div>
</div>
<AdminOnboardingTutorial
:visible="showOnboardingTutorial"
@navigate="handleNavigate"
@close="closeOnboardingTutorial"
@complete="completeOnboardingTutorial"
/>
</main>
</div>
</ClientOnly>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
import { Menu, ChevronUp } from '@lucide/vue'
import { useAuth } from '~/composables/useAuth'
import { usePermissions } from '~/composables/usePermissions'
import { fetchRolesConfig } from '~/composables/usePermissions'
import { useSiteConfig } from '~/composables/useSiteConfig'
import AdminOnboardingTutorial from '~/components/Admin/AdminOnboardingTutorial.vue'
// 使用站点配置
const { siteTitle, initSiteConfig } = useSiteConfig()
// 导入组件
// 页面元数据
definePageMeta({
layout: false
})
// 响应式数据
const activeTab = ref('overview')
const currentUser = ref(null)
const sidebarOpen = ref(false)
const showBackToTop = ref(false)
const showOnboardingTutorial = ref(false)
const contentScrollRef = ref(null)
const beforeNavigateHooks = ref([])
const tabRefreshKey = ref(0)
let revisionInterval = null
let lastDataRevision = ''
// 提供注册导航拦截钩子的方法
const registerBeforeNavigate = (hook) => {
beforeNavigateHooks.value.push(hook)
return () => {
const index = beforeNavigateHooks.value.indexOf(hook)
if (index > -1) {
beforeNavigateHooks.value.splice(index, 1)
}
}
}
provide('registerBeforeNavigate', registerBeforeNavigate)
// 服务
let auth = null
const permissions = usePermissions()
const fetchDataRevision = async () => {
try {
if (!auth || !auth.isAuthenticated.value) return ''
const authConfig = auth.getAuthConfig()
const result = await $fetch('/api/songs/revision', {
...authConfig,
query: { t: Date.now() }
})
return result?.revision || ''
} catch (e) {
console.error('获取数据修订号失败:', e)
return ''
}
}
const startRevisionPolling = async () => {
if (revisionInterval) clearInterval(revisionInterval)
lastDataRevision = await fetchDataRevision()
revisionInterval = setInterval(async () => {
const next = await fetchDataRevision()
if (!next || next === lastDataRevision) return
lastDataRevision = next
tabRefreshKey.value++
}, 5000)
}
// 方法
const getPageTitle = () => {
const titles = {
overview: '数据概览',
'workflow-tutorial': '流程与教程',
'data-analysis': '数据分析',
songs: '歌曲管理',
schedule: '排期管理',
print: '打印排期',
'playlist-card': '图片歌单',
users: '用户管理',
blacklist: '黑名单管理',
'api-keys': 'API密钥管理',
notifications: '通知管理',
'smtp-config': '邮件配置',
playtimes: '播出时段',
'request-times': '投稿管理',
semesters: '学期管理',
'expected-play-dates': '选曲日期',
'site-config': '站点配置',
changelog: '更新日志',
database: '数据库操作',
password: '修改密码'
}
return titles[activeTab.value] || '管理后台'
}
// 动态页面标题
const dynamicTitle = computed(() => {
const currentPageTitle = getPageTitle()
if (siteTitle && siteTitle.value) {
return `${currentPageTitle} | ${siteTitle.value}`
}
return `${currentPageTitle} | 校园广播站点歌系统`
})
// 监听activeTab变化,更新页面标题
watch(
activeTab,
() => {
if (typeof document !== 'undefined') {
document.title = dynamicTitle.value
}
},
{ immediate: true }
)
// 监听siteTitle变化,更新页面标题
watch(
() => siteTitle?.value,
() => {
if (typeof document !== 'undefined') {
document.title = dynamicTitle.value
}
}
)
const getRoleDisplayName = (role) => {
const roleNames = {
USER: '普通用户',
SONG_ADMIN: '歌曲管理员',
ADMIN: '超级管理员',
SUPER_ADMIN: '超级管理员'
}
return roleNames[role] || role
}
const handleLogout = async () => {
if (auth) {
await auth.logout()
await navigateTo('/login')
}
}
// 导航方法
const handleNavigate = async (tab) => {
if (activeTab.value === tab) return
// 检查是否有拦截
for (const hook of beforeNavigateHooks.value) {
if (!(await hook(tab))) return
}
activeTab.value = tab
// 移动端点击导航后关闭侧边栏
if (window.innerWidth <= 768) {
closeSidebar()
}
}
// 侧边栏控制方法
const toggleSidebar = () => {
sidebarOpen.value = !sidebarOpen.value
}
const closeSidebar = () => {
sidebarOpen.value = false
}
const openOnboardingTutorial = () => {
showOnboardingTutorial.value = true
}
const closeOnboardingTutorial = () => {
showOnboardingTutorial.value = false
}
const completeOnboardingTutorial = () => {
showOnboardingTutorial.value = false
if (typeof window !== 'undefined') {
localStorage.setItem('voicehub_admin_tutorial_seen_v1', 'true')
}
}
// 监听窗口大小变化,大屏幕时自动关闭移动端侧边栏
const handleResize = () => {
if (window.innerWidth > 768) {
sidebarOpen.value = false
}
}
// 返回顶部功能
const scrollToTop = () => {
if (contentScrollRef.value) {
contentScrollRef.value.scrollTo({ top: 0, behavior: 'smooth' })
}
}
// 监听滚动事件
const handleScroll = () => {
showBackToTop.value = (contentScrollRef.value?.scrollTop || 0) > 300
}
// 触摸手势支持
let touchStartX = 0
let touchStartY = 0
let touchEndX = 0
let touchEndY = 0
const handleTouchStart = (e) => {
touchStartX = e.changedTouches[0].screenX
touchStartY = e.changedTouches[0].screenY
}
const handleTouchEnd = (e) => {
touchEndX = e.changedTouches[0].screenX
touchEndY = e.changedTouches[0].screenY
handleSwipe()
}
const handleSwipe = () => {
const deltaX = touchEndX - touchStartX
const deltaY = touchEndY - touchStartY
const minSwipeDistance = 50
// 只在移动端处理滑动手势
if (window.innerWidth > 768) return
// 水平滑动距离大于垂直滑动距离,且超过最小滑动距离
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
if (deltaX > 0 && touchStartX < 50) {
// 从左边缘向右滑动,打开侧边栏
sidebarOpen.value = true
} else if (deltaX < 0 && sidebarOpen.value) {
// 向左滑动,关闭侧边栏
sidebarOpen.value = false
}
}
}
// 生命周期
onMounted(async () => {
// 初始化站点配置
await initSiteConfig()
// 初始化服务
auth = useAuth()
await fetchRolesConfig()
// 检查认证状态(plugin已经初始化过了)
if (!auth.isAuthenticated.value) {
await navigateTo('/login')
return
}
// 检查用户是否有访问后台的权限
if (!permissions.canAccessAdmin.value) {
await navigateTo('/')
return
}
currentUser.value = auth.user.value
// 设置默认页面
const userPages = permissions.getUserPages.value
if (userPages.length > 0 && !userPages.includes(activeTab.value)) {
activeTab.value = userPages[0]
}
// 设置初始页面标题
if (typeof document !== 'undefined') {
document.title = dynamicTitle.value
}
// 添加窗口大小监听器
window.addEventListener('resize', handleResize)
contentScrollRef.value?.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
// 添加双击关闭侧边栏事件
document.addEventListener('dblclick', (e) => {
if (window.innerWidth <= 768 && sidebarOpen.value) {
closeSidebar()
}
})
if (!localStorage.getItem('voicehub_admin_tutorial_seen_v1')) {
setTimeout(() => {
showOnboardingTutorial.value = true
}, 800)
}
await startRevisionPolling()
})
// 组件卸载时清理事件监听器
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
contentScrollRef.value?.removeEventListener('scroll', handleScroll)
if (revisionInterval) clearInterval(revisionInterval)
})
</script>
<style scoped>
.admin-layout {
display: flex;
min-height: 100vh;
background: #0a0a0a;
color: #ffffff;
position: relative;
}
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.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;
}
@media (max-width: 768px) {
.admin-main-header {
padding-top: max(0px, env(safe-area-inset-top));
}
.admin-content-area {
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
}
.back-to-top-btn {
right: 1rem;
bottom: calc(1rem + env(safe-area-inset-bottom));
padding: 0.65rem;
}
}
</style>
+3568
View File
File diff suppressed because it is too large Load Diff
+336
View File
@@ -0,0 +1,336 @@
<template>
<div class="auth-layout">
<div class="auth-container">
<div class="form-section">
<div class="form-header">
<div class="logo-row">
<img
:src="brandLogoSrc"
alt="Brand Logo"
class="brand-logo-center"
width="96"
height="96"
decoding="async"
fetchpriority="high"
>
<div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider" />
<img
v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()"
:src="schoolLogoHomeUrl"
alt="学校Logo"
class="school-logo"
width="160"
height="160"
decoding="async"
fetchpriority="high"
>
</div>
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
<div class="header-divider" />
</div>
<ClientOnly>
<LoginForm />
</ClientOnly>
</div>
</div>
<!-- 页脚信息显示 -->
<SiteFooter />
</div>
</template>
<script setup>
import { onMounted, computed, ref } from 'vue'
import LoginForm from '~/components/Auth/LoginForm.vue'
import logo from '~~/public/images/logo.png'
// 使用站点配置
const { siteTitle, initSiteConfig, logoUrl, schoolLogoHomeUrl, icp: icpNumber } = useSiteConfig()
// 主品牌Logo优先使用SVG,其次使用站点配置中非ICO的地址
const brandLogoSrc = computed(() => {
const url = logoUrl.value
if (url && !url.endsWith('.ico')) return url
return logo
})
// 在组件挂载后初始化站点配置
onMounted(async () => {
// 初始化站点配置
await initSiteConfig()
// 设置页面标题
if (typeof document !== 'undefined' && siteTitle.value) {
document.title = `登录 | ${siteTitle.value}`
}
})
</script>
<style scoped>
.auth-layout {
min-height: 100vh;
background: var(--bg-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 20px;
/* 响应式尺寸变量 */
--brand-logo-size: clamp(48px, 8vw, 96px);
--school-logo-size: clamp(96px, 16vw, 160px);
--logo-gap: clamp(12px, 2vw, 24px);
--divider-height: clamp(32px, 10vw, 96px);
--content-footer-gap: clamp(16px, 4vh, 40px);
}
.auth-container {
width: 100%;
max-width: 480px;
background: var(--bg-secondary);
border-radius: var(--radius-2xl);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-lg);
overflow: hidden;
margin: auto 0; /* 在纵向布局中居中 */
margin-bottom: var(--content-footer-gap); /* 与底部footer保持最小距离 */
}
.brand-section {
background: var(--bg-secondary);
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
border-right: 1px solid var(--border-primary);
}
.brand-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.brand-content {
position: relative;
z-index: 1;
text-align: center;
color: var(--text-primary);
}
.logo-section {
margin-bottom: 32px;
}
.brand-logo {
width: 160px;
height: auto;
margin-bottom: 24px;
object-fit: contain;
transition:
transform var(--transition-slow),
filter var(--transition-slow);
}
.brand-logo:hover {
transform: translateY(-3px) scale(1.02);
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25));
}
.brand-title {
font-size: 36px;
font-weight: var(--font-bold);
margin: 0;
color: var(--text-primary);
letter-spacing: 0.5px;
}
.brand-description {
font-size: 18px;
color: var(--text-secondary);
margin: 0 0 40px 0;
line-height: 1.6;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 20px;
text-align: left;
}
.feature-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
backdrop-filter: none;
border: 1px solid var(--border-secondary);
transition:
border-color var(--transition-normal),
background var(--transition-normal),
transform var(--transition-normal);
}
.feature-item:hover {
background: var(--bg-hover);
border-color: var(--card-hover-border);
transform: translateY(-2px);
}
.feature-icon {
width: 24px;
height: 24px;
color: var(--text-secondary);
flex-shrink: 0;
}
.feature-item span {
font-size: 16px;
font-weight: var(--font-medium);
color: var(--text-primary);
}
.form-section {
padding: 40px 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.form-header {
text-align: center;
margin-bottom: 20px;
}
.logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: var(--logo-gap);
margin-bottom: 12px;
}
.logo-divider {
width: 1px;
height: var(--divider-height);
background: var(--border-secondary);
}
.brand-logo-center {
width: var(--brand-logo-size);
height: var(--brand-logo-size);
margin: 0;
object-fit: contain;
max-width: 100%;
max-height: 100%;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.school-logo {
width: var(--school-logo-size);
height: var(--school-logo-size);
margin: 0;
object-fit: contain;
max-width: 100%;
max-height: 100%;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.form-title {
font-size: 24px;
font-weight: var(--font-bold);
margin: 0;
color: var(--text-primary);
}
.header-divider {
height: 1px;
background: var(--border-secondary);
margin: 14px auto 0;
width: 100%;
}
/* 隐藏品牌区的纹理伪元素以保持简约视觉 */
.brand-section::before {
background: none;
opacity: 0;
display: none;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.auth-container {
max-width: 500px;
}
.form-section {
padding: 36px 26px;
}
.form-title {
font-size: 22px;
}
}
@media (max-width: 768px) {
.auth-layout {
padding: 10px;
}
.auth-container {
border-radius: 16px;
min-height: auto;
}
.form-section {
padding: 30px 20px;
}
.feature-list {
gap: 16px;
}
.feature-item {
padding: 12px 16px;
}
}
.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);
}
@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>
+870
View File
@@ -0,0 +1,870 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-200 pb-24">
<!-- 顶部导航栏 -->
<div
class="sticky top-0 z-30 bg-zinc-950/80 backdrop-blur-xl border-b border-zinc-900/50 px-4 py-4 mb-8"
>
<div class="max-w-[1000px] mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<button
class="p-2 hover:bg-zinc-900 rounded-xl transition-all text-zinc-400 hover:text-zinc-100"
@click="goBack"
>
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-xl font-black text-zinc-100 tracking-tight">消息设置</h1>
<p class="text-[10px] text-zinc-500 font-medium uppercase tracking-widest mt-0.5">
Notification Settings
</p>
</div>
</div>
<button
:disabled="loading || saving"
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 disabled:opacity-50"
@click="saveSettings"
>
<template v-if="saving"> <Loader2 :size="14" class="animate-spin" /> 保存中... </template>
<template v-else> <Save :size="14" /> 保存设置 </template>
</button>
</div>
</div>
<div class="max-w-[1000px] mx-auto px-4">
<div v-if="loading" class="flex flex-col items-center justify-center py-32">
<Loader2 :size="32" class="text-blue-500 animate-spin mb-4" />
<p class="text-zinc-500 text-sm font-medium">加载设置中...</p>
</div>
<div v-else class="space-y-8">
<!-- 站内通知设置 -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl">
<Bell :size="20" class="text-blue-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>
<div class="grid grid-cols-1 md:grid-cols-2 gap-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-1">当您投稿的歌曲被选中播放时通知您</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"
>
</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-1">当您投稿的歌曲播放完成时通知您</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"
>
</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-1">当您投稿的歌曲获得投票时通知您</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"
>
</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-1">接收系统重要通知和公告</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"
>
</div>
</div>
<!-- 投票阈值设置 -->
<div :class="[itemClass, 'md:col-span-1']">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">投票通知阈值</h3>
<p class="text-[11px] text-zinc-500 mt-1">当投票数达到此阈值时才发送通知</p>
</div>
<div class="flex items-center gap-2">
<input
v-model.number="localSettings.songVotedThreshold"
type="number"
max="100"
min="1"
class="w-16 bg-zinc-950 border border-zinc-800 rounded-lg px-2 py-1.5 text-xs text-center focus:outline-none focus:border-blue-500/30"
>
<span class="text-[10px] font-black text-zinc-600 uppercase">票</span>
</div>
</div>
<!-- 通知刷新间隔 -->
<div :class="[itemClass, 'md:col-span-1']">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">通知刷新间隔</h3>
<p class="text-[11px] text-zinc-500 mt-1">设置系统自动检查新通知的时间间隔</p>
</div>
<div class="flex items-center gap-3">
<input
v-model.number="localSettings.refreshInterval"
type="range"
max="300"
min="30"
step="30"
class="w-24 h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
>
<span class="text-[11px] font-bold text-blue-500 min-w-[40px] text-right"
>{{ localSettings.refreshInterval }}s</span
>
</div>
</div>
</div>
</section>
<!-- 社交账号绑定 -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="p-2.5 bg-purple-500/10 rounded-xl">
<Link :size="20" class="text-purple-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>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- 邮箱绑定 -->
<div v-if="smtpEnabled" :class="cardClass">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-zinc-950 rounded-lg border border-zinc-800">
<Mail :size="16" class="text-zinc-400" />
</div>
<h3 class="text-sm font-bold text-zinc-200">邮箱消息通知</h3>
</div>
<div class="space-y-4">
<div v-if="userEmail" class="p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl">
<div class="flex items-center justify-between">
<div>
<p
class="text-[10px] text-zinc-500 font-black uppercase tracking-widest mb-1"
>
当前绑定邮箱
</p>
<p class="text-sm font-medium text-zinc-200">{{ userEmail }}</p>
</div>
<div
:class="[
'px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider',
emailVerified
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-amber-500/10 text-amber-500'
]"
>
{{ emailVerified ? '已验证' : '待验证' }}
</div>
</div>
</div>
<!-- 未绑定状态 -->
<div v-if="!userEmail" class="space-y-3">
<p class="text-xs text-zinc-500">绑定邮箱后,您可以接收到实时的邮件通知提醒。</p>
<div class="flex flex-col sm:flex-row gap-2">
<input
v-model="newEmail"
:disabled="bindingEmail"
type="email"
placeholder="请输入邮箱地址"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30 w-full sm:w-auto"
>
<button
:disabled="!newEmail || bindingEmail"
class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap"
@click="bindEmail"
>
{{ bindingEmail ? '请稍候...' : '立即绑定' }}
</button>
</div>
</div>
<!-- 待验证状态 -->
<div v-else-if="!emailVerified" class="space-y-4 pt-2">
<div
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle :size="14" class="text-blue-500 shrink-0 mt-0.5" />
<p class="text-[11px] text-zinc-500 leading-relaxed">
验证码已发送至您的邮箱,请在 5 分钟内完成验证。若未收到邮件,请检查垃圾箱。
</p>
</div>
<div class="space-y-3">
<input
v-model="emailCode"
type="text"
maxlength="6"
placeholder="输入 6 位数字验证码"
:class="[
'w-full bg-zinc-950 border rounded-xl px-4 py-3 text-lg font-black tracking-[0.5em] text-center focus:outline-none transition-all',
emailCodeError
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
]"
@input="handleEmailCodeInput"
@keydown="handleEmailCodeKeydown"
>
<div class="grid grid-cols-2 gap-2">
<button
:disabled="bindingEmail || emailCode.length !== 6"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50"
@click="verifyEmailCode"
>
{{ bindingEmail ? '验证中...' : '确认验证' }}
</button>
<button
:disabled="resendingEmail"
class="px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
@click="resendVerificationEmail"
>
{{ resendingEmail ? '发送中...' : '重新发送' }}
</button>
</div>
<button
class="w-full py-2 text-zinc-600 hover:text-zinc-400 text-[10px] font-black uppercase tracking-widest transition-all"
@click="changeEmail"
>
更换邮箱地址
</button>
</div>
</div>
<!-- 已验证状态 -->
<div v-else class="flex gap-2 pt-2">
<button
class="flex-1 py-2.5 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all"
@click="changeEmail"
>
更换邮箱
</button>
<button
:disabled="unbindingEmail"
class="flex-1 py-2.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"
@click="unbindEmail"
>
{{ unbindingEmail ? '正在解绑...' : '解绑邮箱' }}
</button>
</div>
</div>
</div>
<!-- MeoW 账号绑定 -->
<div :class="[cardClass, 'border-blue-500/20 bg-blue-500/[0.02]']">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
<Smartphone :size="16" class="text-blue-500" />
</div>
<h3 class="text-sm font-bold text-zinc-200">MeoW App 推送</h3>
</div>
</div>
<div class="space-y-4">
<div
v-if="localSettings.meowUserId"
class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl"
>
<div class="flex items-center justify-between">
<div>
<p
class="text-[10px] text-blue-500/60 font-black uppercase tracking-widest mb-1"
>
当前绑定 ID
</p>
<p class="text-sm font-black text-blue-500 tracking-tight">
{{ localSettings.meowUserId }}
</p>
</div>
<div
class="px-2 py-0.5 bg-blue-500 text-white rounded-full text-[10px] font-black uppercase tracking-wider"
>
已连接
</div>
</div>
</div>
<!-- 未绑定状态 -->
<div v-if="!localSettings.meowUserId" class="space-y-3">
<p class="text-xs text-zinc-500">
通过 MeoW 客户端接收更及时的系统通知和歌曲状态变更提醒。
</p>
<!-- 第一步:输入用户ID -->
<div v-if="!verificationSent" class="flex flex-col sm:flex-row gap-2">
<input
v-model="meowUserId"
:disabled="binding"
type="text"
placeholder="请输入 MeoW 用户 ID"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30 w-full sm:w-auto"
>
<button
:disabled="!meowUserId || binding"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50 shadow-lg shadow-blue-900/20 whitespace-nowrap"
@click="sendVerificationCode"
>
{{ binding ? '发送中...' : '发送验证码' }}
</button>
</div>
<!-- 第二步:输入验证码 -->
<div v-else class="space-y-4">
<div
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle :size="14" class="text-blue-500 shrink-0 mt-0.5" />
<p class="text-[11px] text-zinc-500 leading-relaxed">
验证码已发送至 MeoW ID:
<span class="font-bold text-zinc-200">{{ meowUserId }}</span
>,请在客户端查收。
</p>
</div>
<div class="space-y-3">
<input
v-model="verificationCode"
type="text"
maxlength="6"
placeholder="输入 6 位验证码"
:class="[
'w-full bg-zinc-950 border rounded-xl px-4 py-3 text-lg font-black tracking-[0.5em] text-center focus:outline-none transition-all',
verificationCodeError
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
]"
@input="handleVerificationCodeInput"
@keydown="handleVerificationCodeKeydown"
>
<div class="grid grid-cols-2 gap-2">
<button
:disabled="binding || verificationCode.length !== 6"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50"
@click="verifyAndBind"
>
{{ binding ? '验证中...' : '确认绑定' }}
</button>
<button
:disabled="binding"
class="px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
@click="cancelVerification"
>
取消
</button>
</div>
</div>
</div>
</div>
<!-- 已绑定状态 -->
<div v-else class="pt-2">
<button
class="w-full py-2.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"
@click="showUnbindConfirm"
>
解除 MeoW 账号绑定
</button>
</div>
</div>
</div>
</div>
</section>
</div>
</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>
import { nextTick, onMounted, ref } from 'vue'
import {
Bell,
Mail,
Link,
MessageSquare,
Clock,
ArrowLeft,
Save,
Shield,
Trash2,
User,
ExternalLink,
CheckCircle2,
AlertCircle,
Loader2,
Smartphone
} from '@lucide/vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useSiteConfig } from '~/composables/useSiteConfig'
import { useToast } from '~/composables/useToast'
const { siteTitle, smtpEnabled, initSiteConfig } = useSiteConfig()
const { showToast } = useToast()
// 样式类常量
const sectionClass = 'bg-zinc-900/40 border border-zinc-900 rounded-3xl p-6 md:p-8 shadow-2xl'
const cardClass =
'bg-zinc-950/40 border border-zinc-800/50 rounded-2xl p-5 transition-all hover:border-zinc-700/50'
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 loading = ref(true)
const saving = ref(false)
const binding = ref(false)
// 通知设置
const localSettings = ref({
songSelectedNotify: true,
songPlayedNotify: true,
songVotedNotify: true,
songVotedThreshold: 5,
systemNotify: true,
refreshInterval: 60,
meowUserId: ''
})
// MeoW 绑定相关
const meowUserId = ref('')
const verificationSent = ref(false)
const verificationCode = ref('')
const verificationCodeError = ref(false)
// 邮箱绑定相关
const userEmail = ref('')
const emailVerified = ref(false)
const newEmail = ref('')
const bindingEmail = ref(false)
const resendingEmail = ref(false)
const unbindingEmail = ref(false)
const emailCode = ref('')
const emailCodeError = ref(false)
// 确认对话框相关
const showConfirmDialog = ref(false)
const confirmDialog = ref({
title: '',
message: '',
type: 'warning',
loading: false,
onConfirm: () => {},
onCancel: () => {}
})
// 通知显示函数
const showNotification = (message, type = 'info') => {
showToast(message, type)
}
// 返回主页
const goBack = () => {
navigateTo('/')
}
// 页面初始化
onMounted(async () => {
await initSiteConfig()
// 设置页面标题
if (typeof document !== 'undefined' && siteTitle.value) {
document.title = `通知设置 | ${siteTitle.value}`
}
await loadSettings()
})
// 处理验证码输入
const handleVerificationCodeInput = (event) => {
const value = event.target.value.replace(/[^0-9]/g, '')
verificationCode.value = value
if (verificationCodeError.value) {
verificationCodeError.value = false
}
}
// 处理验证码输入键盘事件
const handleVerificationCodeKeydown = (event) => {
if (event.key === 'Enter' && verificationCode.value.length === 6) {
verifyAndBind()
}
}
// 加载设置
const loadSettings = async () => {
try {
loading.value = true
const response = await $fetch('/api/notifications/settings')
if (response.success) {
localSettings.value = {
songSelectedNotify: response.data.songSelectedNotify || false,
songPlayedNotify: response.data.songPlayedNotify || false,
songVotedNotify: response.data.songVotedNotify || false,
songVotedThreshold: response.data.songVotedThreshold || 5,
systemNotify: response.data.systemNotify || true,
refreshInterval: response.data.refreshInterval || 60,
meowUserId: response.data.meowUserId || ''
}
userEmail.value = response.data.userEmail || ''
emailVerified.value = response.data.emailVerified || false
}
} catch (err) {
console.error('加载设置失败:', err)
showNotification('加载设置失败,请刷新页面重试', 'error')
} finally {
loading.value = false
}
}
// 发送验证码
const sendVerificationCode = async () => {
if (!meowUserId.value.trim()) {
showNotification('请输入 MeoW 用户 ID', 'error')
return
}
try {
binding.value = true
const response = await $fetch('/api/meow/bind', {
method: 'POST',
body: {
action: 'send_verification',
meowId: meowUserId.value.trim()
}
})
if (response.success) {
verificationSent.value = true
showNotification('验证码已发送到您的 MeoW 账号', 'success')
} else {
showNotification(response.message || '发送验证码失败', 'error')
}
} catch (err) {
showNotification(err.data?.message || '发送验证码失败', 'error')
} finally {
binding.value = false
}
}
// 验证并绑定
const verifyAndBind = async () => {
if (!verificationCode.value || verificationCode.value.length !== 6) {
showNotification('请输入6位验证码', 'error')
verificationCodeError.value = true
return
}
try {
binding.value = true
const response = await $fetch('/api/meow/bind', {
method: 'POST',
body: {
action: 'verify_and_bind',
meowId: meowUserId.value.trim(),
verificationCode: verificationCode.value
}
})
if (response.success) {
localSettings.value.meowUserId = meowUserId.value.trim()
meowUserId.value = ''
verificationCode.value = ''
verificationSent.value = false
showNotification('MeoW 账号绑定成功!', 'success')
} else {
showNotification(response.message || '验证失败', 'error')
verificationCodeError.value = true
}
} catch (err) {
showNotification(err.data?.message || '验证失败', 'error')
verificationCodeError.value = true
} finally {
binding.value = false
}
}
// 取消验证
const cancelVerification = () => {
verificationSent.value = false
verificationCode.value = ''
meowUserId.value = ''
}
// 显示解绑确认对话框
const showUnbindConfirm = () => {
confirmDialog.value = {
title: '解绑 MeoW 账号',
message: '确定要解绑 MeoW 账号吗?解绑后将无法接收推送通知。',
type: 'danger',
loading: false,
onConfirm: performUnbind,
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
// 执行解绑操作
const performUnbind = async () => {
try {
confirmDialog.value.loading = true
const response = await $fetch('/api/meow/unbind', { method: 'POST' })
if (response.success) {
localSettings.value.meowUserId = ''
showNotification('MeoW 账号已解绑', 'success')
showConfirmDialog.value = false
} else {
showNotification(response.message || '解绑失败', 'error')
}
} catch (err) {
showNotification(err.data?.message || '解绑失败', 'error')
} finally {
confirmDialog.value.loading = false
}
}
// 邮箱绑定相关方法
const bindEmail = async () => {
if (!newEmail.value) {
showNotification('请输入邮箱地址', 'error')
return
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(newEmail.value)) {
showNotification('请输入有效的邮箱地址', 'error')
return
}
bindingEmail.value = true
try {
const response = await $fetch('/api/user/email/bind', {
method: 'POST',
body: { email: newEmail.value }
})
if (response.success) {
userEmail.value = newEmail.value
emailVerified.value = false
newEmail.value = ''
showNotification('验证码已发送,请查收邮箱', 'success')
} else {
showNotification(response.message || '绑定失败', 'error')
}
} catch (err) {
showNotification(err.data?.message || '绑定失败', 'error')
} finally {
bindingEmail.value = false
}
}
const handleEmailCodeInput = (event) => {
const value = event.target.value.replace(/[^0-9]/g, '')
emailCode.value = value
if (emailCodeError.value) emailCodeError.value = false
}
const handleEmailCodeKeydown = (event) => {
if (event.key === 'Enter' && emailCode.value.length === 6) verifyEmailCode()
}
const verifyEmailCode = async () => {
if (emailCode.value.length !== 6) {
emailCodeError.value = true
showNotification('请输入6位验证码', 'error')
return
}
try {
bindingEmail.value = true
const response = await $fetch('/api/user/email/verify-code', {
method: 'POST',
body: { email: userEmail.value, code: emailCode.value }
})
if (response.success) {
emailVerified.value = true
emailCode.value = ''
showNotification('邮箱验证成功', 'success')
} else {
emailCodeError.value = true
showNotification(response.message || '验证失败', 'error')
}
} catch (err) {
emailCodeError.value = true
showNotification(err.data?.message || '验证失败', 'error')
} finally {
bindingEmail.value = false
}
}
const changeEmail = () => {
confirmDialog.value = {
title: '更换邮箱',
message: '更换邮箱将清除当前绑定的邮箱信息,需要重新验证新邮箱。确定要继续吗?',
type: 'warning',
loading: false,
onConfirm: performChangeEmail,
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const performChangeEmail = () => {
userEmail.value = ''
emailVerified.value = false
newEmail.value = ''
emailCode.value = ''
emailCodeError.value = false
showConfirmDialog.value = false
showNotification('已清除邮箱信息', 'info')
}
const resendVerificationEmail = async () => {
try {
resendingEmail.value = true
const response = await $fetch('/api/user/email/resend-verification', { method: 'POST' })
if (response.success) {
emailCode.value = ''
emailCodeError.value = false
showNotification('验证码已重新发送', 'success')
} else {
showNotification(response.message || '发送失败', 'error')
}
} catch (err) {
showNotification(err.data?.message || '发送失败', 'error')
} finally {
resendingEmail.value = false
}
}
const unbindEmail = async () => {
confirmDialog.value = {
title: '确认解绑邮箱',
message: '解绑后将无法接收邮件通知,确定要继续吗?',
type: 'warning',
loading: false,
onConfirm: performEmailUnbind,
onCancel: () => {
showConfirmDialog.value = false
}
}
showConfirmDialog.value = true
}
const performEmailUnbind = async () => {
try {
confirmDialog.value.loading = true
const response = await $fetch('/api/user/email/unbind', { method: 'POST' })
if (response.success) {
userEmail.value = ''
emailVerified.value = false
showNotification('邮箱已解绑', 'success')
showConfirmDialog.value = false
} else {
showNotification(response.message || '解绑失败', 'error')
}
} catch (err) {
showNotification(err.data?.message || '解绑失败', 'error')
} finally {
confirmDialog.value.loading = false
}
}
// 保存设置
const saveSettings = async () => {
try {
saving.value = true
const response = await $fetch('/api/notifications/settings', {
method: 'POST',
body: localSettings.value
})
if (response.success) {
showNotification('设置保存成功', 'success')
} else {
showNotification(response.message || '保存失败', 'error')
}
} catch (err) {
showNotification(err.data?.message || '保存失败', 'error')
} finally {
saving.value = false
}
}
</script>
<style scoped>
input[type='number']::-webkit-inner-spin-button,
input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
input[type='number'] {
-moz-appearance: textfield;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-4px);
}
75% {
transform: translateX(4px);
}
}
</style>
+336
View File
@@ -0,0 +1,336 @@
<template>
<div class="auth-layout">
<div class="auth-container">
<div class="form-section">
<div class="form-header">
<div class="logo-row">
<img
:src="brandLogoSrc"
alt="Brand Logo"
class="brand-logo-center"
width="96"
height="96"
decoding="async"
fetchpriority="high"
>
<div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider" />
<img
v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()"
:src="schoolLogoHomeUrl"
alt="学校Logo"
class="school-logo"
width="160"
height="160"
decoding="async"
fetchpriority="high"
>
</div>
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
<div class="header-divider" />
</div>
<ClientOnly>
<RegisterForm />
</ClientOnly>
</div>
</div>
<!-- 页脚信息显示 -->
<SiteFooter />
</div>
</template>
<script setup>
import { onMounted, computed, ref } from 'vue'
import RegisterForm from '~/components/Auth/RegisterForm.vue'
import logo from '~~/public/images/logo.png'
// 使用站点配置
const { siteTitle, initSiteConfig, logoUrl, schoolLogoHomeUrl, icp: icpNumber } = useSiteConfig()
// 主品牌Logo优先使用SVG,其次使用站点配置中非ICO的地址
const brandLogoSrc = computed(() => {
const url = logoUrl.value
if (url && !url.endsWith('.ico')) return url
return logo
})
// 在组件挂载后初始化站点配置
onMounted(async () => {
// 初始化站点配置
await initSiteConfig()
// 设置页面标题
if (typeof document !== 'undefined' && siteTitle.value) {
document.title = `注册 | ${siteTitle.value}`
}
})
</script>
<style scoped>
.auth-layout {
min-height: 100vh;
background: var(--bg-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 20px;
/* 响应式尺寸变量 */
--brand-logo-size: clamp(48px, 8vw, 96px);
--school-logo-size: clamp(96px, 16vw, 160px);
--logo-gap: clamp(12px, 2vw, 24px);
--divider-height: clamp(32px, 10vw, 96px);
--content-footer-gap: clamp(16px, 4vh, 40px);
}
.auth-container {
width: 100%;
max-width: 480px;
background: var(--bg-secondary);
border-radius: var(--radius-2xl);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-lg);
overflow: hidden;
margin: auto 0; /* 在纵向布局中居中 */
margin-bottom: var(--content-footer-gap); /* 与底部footer保持最小距离 */
}
.brand-section {
background: var(--bg-secondary);
padding: 60px 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
border-right: 1px solid var(--border-primary);
}
.brand-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
opacity: 0.3;
}
.brand-content {
position: relative;
z-index: 1;
text-align: center;
color: var(--text-primary);
}
.logo-section {
margin-bottom: 32px;
}
.brand-logo {
width: 160px;
height: auto;
margin-bottom: 24px;
object-fit: contain;
transition:
transform var(--transition-slow),
filter var(--transition-slow);
}
.brand-logo:hover {
transform: translateY(-3px) scale(1.02);
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25));
}
.brand-title {
font-size: 36px;
font-weight: var(--font-bold);
margin: 0;
color: var(--text-primary);
letter-spacing: 0.5px;
}
.brand-description {
font-size: 18px;
color: var(--text-secondary);
margin: 0 0 40px 0;
line-height: 1.6;
}
.feature-list {
display: flex;
flex-direction: column;
gap: 20px;
text-align: left;
}
.feature-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
background: var(--bg-tertiary);
border-radius: var(--radius-lg);
backdrop-filter: none;
border: 1px solid var(--border-secondary);
transition:
border-color var(--transition-normal),
background var(--transition-normal),
transform var(--transition-normal);
}
.feature-item:hover {
background: var(--bg-hover);
border-color: var(--card-hover-border);
transform: translateY(-2px);
}
.feature-icon {
width: 24px;
height: 24px;
color: var(--text-secondary);
flex-shrink: 0;
}
.feature-item span {
font-size: 16px;
font-weight: var(--font-medium);
color: var(--text-primary);
}
.form-section {
padding: 40px 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.form-header {
text-align: center;
margin-bottom: 20px;
}
.logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: var(--logo-gap);
margin-bottom: 12px;
}
.logo-divider {
width: 1px;
height: var(--divider-height);
background: var(--border-secondary);
}
.brand-logo-center {
width: var(--brand-logo-size);
height: var(--brand-logo-size);
margin: 0;
object-fit: contain;
max-width: 100%;
max-height: 100%;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.school-logo {
width: var(--school-logo-size);
height: var(--school-logo-size);
margin: 0;
object-fit: contain;
max-width: 100%;
max-height: 100%;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.form-title {
font-size: 24px;
font-weight: var(--font-bold);
margin: 0;
color: var(--text-primary);
}
.header-divider {
height: 1px;
background: var(--border-secondary);
margin: 14px auto 0;
width: 100%;
}
/* 隐藏品牌区的纹理伪元素以保持简约视觉 */
.brand-section::before {
background: none;
opacity: 0;
display: none;
}
/* 响应式设计 */
@media (max-width: 1024px) {
.auth-container {
max-width: 500px;
}
.form-section {
padding: 36px 26px;
}
.form-title {
font-size: 22px;
}
}
@media (max-width: 768px) {
.auth-layout {
padding: 10px;
}
.auth-container {
border-radius: 16px;
min-height: auto;
}
.form-section {
padding: 30px 20px;
}
.feature-list {
gap: 16px;
}
.feature-item {
padding: 12px 16px;
}
}
.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);
}
@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>
+397
View File
@@ -0,0 +1,397 @@
<template>
<div class="review-page">
<Head>
<Title>我的年度回顾 - VoiceHub</Title>
<Meta name="theme-color" content="#000000" />
<Meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no"
/>
</Head>
<!-- Loading State -->
<div v-if="pending" class="state-screen loading">
<div class="spinner" />
<p class="loading-text">正在生成您的年度回忆...</p>
</div>
<!-- Error State -->
<div v-else-if="error" class="state-screen error">
<Icon class="icon-warning text-4xl text-yellow-500" name="alert-triangle" size="48" />
<div class="text-center">
<h2 class="error-title">获取数据失败</h2>
<p class="error-desc">可能是网络出了点小差错</p>
</div>
<button class="retry-btn" @click="refresh"><span><Icon name="refresh-cw" size="16" /></span> 重试</button>
</div>
<!-- Empty State -->
<div v-else-if="isEmpty" class="state-screen empty">
<div class="empty-icon-circle">
<Icon class="empty-icon text-zinc-500" name="music" size="48" />
</div>
<h2 class="empty-title">今年还没有点歌记录哦</h2>
<p class="empty-desc">快去点一首喜欢的歌吧</p>
<NuxtLink to="/" class="home-btn">去点歌</NuxtLink>
</div>
<!-- Main Content -->
<div
v-else
class="slides-container"
@wheel="handleWheel"
@touchstart="handleTouchStart"
@touchend="handleTouchEnd"
>
<!-- Progress Indicators -->
<div class="indicators">
<button
v-for="(slide, idx) in slides"
:key="slide.id"
class="indicator-dot"
:class="{ active: idx === currentIndex }"
@click="goToSlide(idx)"
/>
</div>
<!-- Slide Transition -->
<Transition :name="transitionName">
<component
:is="currentSlideComponent"
:key="currentIndex"
:data="data"
:active="!isAnimating"
class="slide-wrapper"
/>
</Transition>
<!-- Scroll Hint -->
<Transition name="fade">
<div v-if="currentIndex === 0" class="scroll-hint">
<span>上滑开启</span>
<svg
class="animate-bounce"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M7 13l5 5 5-5M7 6l5 5 5-5" />
</svg>
</div>
</Transition>
</div>
</div>
</template>
<script setup>
import IntroSlide from '~/components/year-review/IntroSlide.vue'
import FirstSongSlide from '~/components/year-review/FirstSongSlide.vue'
import StatsSlide from '~/components/year-review/StatsSlide.vue'
import ArtistSlide from '~/components/year-review/ArtistSlide.vue'
import MiscSlide from '~/components/year-review/MiscSlide.vue'
import OutroSlide from '~/components/year-review/OutroSlide.vue'
// 导入全局样式
import '~/assets/css/year-review.css'
const { data: responseData, pending, error, refresh } = await useFetch('/api/user/year-review')
const data = computed(() => {
if (!responseData.value) return null
return responseData.value.data || responseData.value
})
const isEmpty = computed(() => {
if (!data.value) return true
return data.value.totalRequests === 0 && data.value.totalVotes === 0
})
// --- 幻灯片配置 ---
const slides = computed(() => {
if (!data.value) return []
const list = [
{ id: 'intro', component: IntroSlide },
...(data.value.firstSong ? [{ id: 'first', component: FirstSongSlide }] : []),
{ id: 'stats', component: StatsSlide },
...(data.value.topArtist ? [{ id: 'artist', component: ArtistSlide }] : []),
{ id: 'misc', component: MiscSlide },
{ id: 'outro', component: OutroSlide }
]
return list
})
const currentIndex = ref(0)
const direction = ref(1) // 1 for down (next), -1 for up (prev)
const isAnimating = ref(false)
const transitionName = ref('slide-up')
const currentSlideComponent = computed(() => {
return slides.value[currentIndex.value]?.component
})
// --- 导航逻辑 ---
const goToSlide = (index) => {
if (isAnimating.value || index === currentIndex.value) return
if (index < 0 || index >= slides.value.length) return
const newDir = index > currentIndex.value ? 1 : -1
direction.value = newDir
transitionName.value = newDir > 0 ? 'slide-up' : 'slide-down'
isAnimating.value = true
currentIndex.value = index
setTimeout(() => {
isAnimating.value = false
}, 600) // Match transition duration
}
const nextSlide = () => goToSlide(currentIndex.value + 1)
const prevSlide = () => goToSlide(currentIndex.value - 1)
// --- 事件监听 ---
// 键盘
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
const handleKeyDown = (e) => {
if (e.key === 'ArrowDown' || e.key === ' ') {
e.preventDefault()
nextSlide()
} else if (e.key === 'ArrowUp') {
e.preventDefault()
prevSlide()
}
}
// 滚轮(防抖)
let wheelTimeout = null
const handleWheel = (e) => {
if (Math.abs(e.deltaY) > 20 && !isAnimating.value) {
if (wheelTimeout) clearTimeout(wheelTimeout)
wheelTimeout = setTimeout(() => {
if (e.deltaY > 0) nextSlide()
else prevSlide()
}, 50)
}
}
// 触摸
let touchStartY = 0
const handleTouchStart = (e) => {
touchStartY = e.touches[0].clientY
}
const handleTouchEnd = (e) => {
const touchEndY = e.changedTouches[0].clientY
const diff = touchStartY - touchEndY
if (Math.abs(diff) > 50) {
// Threshold
if (diff > 0)
nextSlide() // Swipe Up -> Next
else prevSlide() // Swipe Down -> Prev
}
}
</script>
<style scoped>
.review-page {
position: fixed;
inset: 0;
background-color: #000;
color: #fff;
z-index: 9999;
font-family:
'MiSans',
system-ui,
-apple-system,
sans-serif;
overflow: hidden;
}
/* 状态屏幕 */
.state-screen {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #000;
z-index: 50;
}
.loading .spinner {
width: 3rem;
height: 3rem;
border: 4px solid rgba(255, 255, 255, 0.2);
border-top-color: #8b5cf6; /* brand-purple */
border-radius: 50%;
animation: spin 1s linear infinite;
}
.loading-text {
margin-top: 1rem;
color: #9ca3af;
font-size: 0.875rem;
animation: pulse 2s infinite;
}
.error .icon-warning {
font-size: 3rem;
color: #ef4444;
margin-bottom: 1rem;
}
.error-title {
font-size: 1.25rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.error-desc {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 1.5rem;
}
.retry-btn {
padding: 0.5rem 1.5rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 9999px;
display: flex;
align-items: center;
gap: 0.5rem;
transition: background 0.2s;
}
.retry-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.empty .empty-icon-circle {
width: 5rem;
height: 5rem;
background: #111827;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
}
.empty-icon {
font-size: 2.25rem;
}
.empty-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.empty-desc {
color: #6b7280;
margin-bottom: 2rem;
}
.home-btn {
padding: 0.75rem 2rem;
background: #8b5cf6;
border-radius: 9999px;
font-weight: 700;
box-shadow: 0 10px 15px -3px rgba(139, 92, 246, 0.2);
transition: background 0.2s;
}
.home-btn:hover {
background: #7c3aed;
}
/* 幻灯片容器 */
.slides-container {
width: 100%;
height: 100%;
position: relative;
overflow: hidden;
}
.slide-wrapper {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
will-change: transform;
}
/* 指示器 */
.indicators {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
z-index: 50;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.indicator-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 9999px;
background: rgba(255, 255, 255, 0.3);
transition: all 0.3s;
}
.indicator-dot:hover {
background: rgba(255, 255, 255, 0.6);
}
.indicator-dot.active {
height: 1.5rem;
background: #fff;
}
/* 滚动提示 */
.scroll-hint {
position: absolute;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
z-index: 40;
color: rgba(255, 255, 255, 0.4);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.1em;
pointer-events: none;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.5s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>