初次提交
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user