feat: 迁移自定义功能至 1.5.9.9 版本
包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
@@ -5,21 +5,29 @@
|
||||
<div class="info-section">
|
||||
<div class="info-content">
|
||||
<div class="logo-section">
|
||||
<img alt="VoiceHub Logo" class="brand-logo" :src="getLogo()" />
|
||||
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle }}</h1>
|
||||
<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>{{ locale.welcomeTitle }}</h2>
|
||||
<p>{{ locale.welcomeDesc }}</p>
|
||||
<h2>欢迎使用VoiceHub</h2>
|
||||
<p>为了保障您的账号安全,请设置一个新的密码</p>
|
||||
</div>
|
||||
<div v-else class="security-message">
|
||||
<h2>{{ locale.securityTitle }}</h2>
|
||||
<p>{{ locale.securityDesc }}</p>
|
||||
<h2>账号安全</h2>
|
||||
<p>定期更新密码有助于保护您的账号安全</p>
|
||||
</div>
|
||||
|
||||
<div class="security-tips">
|
||||
<h3>{{ locale.tipsTitle }}</h3>
|
||||
<h3>密码安全建议</h3>
|
||||
<div class="tip-list">
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
@@ -31,7 +39,7 @@
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipMinLength }}</span>
|
||||
<span>至少8个字符</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
@@ -43,7 +51,7 @@
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipCase }}</span>
|
||||
<span>包含大小写字母</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
@@ -55,7 +63,7 @@
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipSpecial }}</span>
|
||||
<span>包含数字和特殊字符</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,27 +74,21 @@
|
||||
<div class="form-section">
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>{{ isFirstLogin ? locale.setNewPassword : locale.changePasswordTitle }}</h2>
|
||||
<p>{{ isFirstLogin ? locale.setNewPasswordDesc : locale.updatePasswordDesc }}</p>
|
||||
<h2>{{ isFirstLogin ? '设置新密码' : '修改密码' }}</h2>
|
||||
<p>{{ isFirstLogin ? '请设置一个安全的密码' : '更新您的登录密码' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="password-form-shell">
|
||||
<ClientOnly>
|
||||
<ChangePasswordForm :is-first-login="isFirstLogin" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<ChangePasswordForm :is-first-login="isFirstLogin" />
|
||||
</ClientOnly>
|
||||
|
||||
<div class="form-footer">
|
||||
<NuxtLink v-if="!requirePasswordChange" class="back-link" to="/">
|
||||
<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>
|
||||
{{ locale.backToHome }}
|
||||
返回主页
|
||||
</NuxtLink>
|
||||
<button v-else class="back-link logout-link" type="button" @click="auth.logout()">
|
||||
<Icon class="shrink-0" name="logout" :size="16" aria-hidden="true" />
|
||||
{{ locale.logout }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,22 +98,15 @@
|
||||
|
||||
<script setup>
|
||||
import ChangePasswordForm from '~/components/Auth/ChangePasswordForm.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useThemeImage } from '~/composables/useThemeImage'
|
||||
import { ref } from 'vue'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
|
||||
// 使用站点配置
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { changePassword: locale } = useLocale()
|
||||
const { getLogo } = useThemeImage()
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const isFirstLogin = computed(() => {
|
||||
const currentUser = auth.user.value
|
||||
return currentUser?.needsInitialPasswordSetup === true
|
||||
})
|
||||
const requirePasswordChange = computed(() => !!auth.user.value?.requirePasswordChange)
|
||||
const isFirstLogin = ref(false)
|
||||
|
||||
// 未登录用户重定向到登录页
|
||||
onMounted(async () => {
|
||||
@@ -122,38 +117,43 @@ onMounted(async () => {
|
||||
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 {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--panel-bg-deepest);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background: var(--bg-primary);
|
||||
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--panel-border-subtle);
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 600px;
|
||||
margin: auto;
|
||||
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: linear-gradient(135deg, var(--color-indigo-hover) 0%, var(--color-collab-hover) 100%);
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -169,7 +169,7 @@ onMounted(async () => {
|
||||
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="%23666" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,10 @@ onMounted(async () => {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--card-bg, #ffffff) 0%, #f0f0f0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-message,
|
||||
@@ -214,7 +217,7 @@ onMounted(async () => {
|
||||
.welcome-message p,
|
||||
.security-message p {
|
||||
font-size: 16px;
|
||||
color: var(--overlay-90);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -241,16 +244,16 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--overlay-20);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-success);
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -264,7 +267,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||
}
|
||||
|
||||
.form-container {
|
||||
@@ -275,11 +278,6 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-form-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
@@ -288,13 +286,13 @@ onMounted(async () => {
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #1a2332);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary-hover);
|
||||
color: var(--text-secondary, #4a5568);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -308,10 +306,10 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--panel-bg-deep);
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--surface-secondary, var(--bg-secondary, #f7fafc));
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-secondary, #4a5568);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -319,9 +317,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--panel-border);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--panel-border-light);
|
||||
background: var(--surface-tertiary, var(--bg-tertiary, #edf2f7));
|
||||
color: var(--text-primary, #1a2332);
|
||||
border-color: var(--border-secondary, #cbd5e1);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
|
||||
+218
-88
@@ -17,91 +17,114 @@
|
||||
<!-- 移动端侧边栏遮罩 -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 bg-bg-primary-60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300"
|
||||
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-bg-primary text-text-primary"
|
||||
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-[#09090b] text-zinc-100"
|
||||
>
|
||||
<header
|
||||
class="h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-border-secondary bg-bg-primary-60 backdrop-blur-xl z-30"
|
||||
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="lg:hidden p-2 text-text-tertiary hover:bg-bg-tertiary rounded-lg transition-colors"
|
||||
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-4">
|
||||
<!-- 这里可以添加顶部操作按钮 -->
|
||||
<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
|
||||
class="flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
|
||||
ref="contentScrollRef"
|
||||
class="admin-content-area flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
|
||||
>
|
||||
<!-- 移动端返回顶部按钮 -->
|
||||
<button
|
||||
v-if="showBackToTop"
|
||||
:aria-label="locale.backToTop"
|
||||
class="fixed bottom-8 right-8 p-3 bg-primary-hover text-text-primary rounded-full shadow-lg hover:bg-primary transition-all z-50"
|
||||
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
|
||||
v-if="activeTab === 'overview' && permissions.canAccessPage('overview')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminOverviewDashboard @navigate="handleNavigate" />
|
||||
</div>
|
||||
<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 === 'songs' && permissions.canAccessPage('songs')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSongManagement />
|
||||
</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 === 'schedule' && permissions.canAccessPage('schedule')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminScheduleManager />
|
||||
</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 === 'print' && permissions.canAccessPage('print')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminSchedulePrinter />
|
||||
</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 === 'data-analysis' && permissions.canAccessPage('data-analysis')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminDataAnalysisPanel />
|
||||
</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 === 'users' && permissions.canAccessPage('users')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminUserManager />
|
||||
</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
|
||||
@@ -151,14 +174,6 @@
|
||||
<LazyAdminBlacklistManager />
|
||||
</div>
|
||||
|
||||
<!-- 年级班级管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'grade-class' && permissions.canAccessPage('grade-class')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminGradeClassManager />
|
||||
</div>
|
||||
|
||||
<!-- 站点配置 -->
|
||||
<div
|
||||
v-if="activeTab === 'site-config' && permissions.canAccessPage('site-config')"
|
||||
@@ -167,12 +182,20 @@
|
||||
<LazyAdminSiteConfigManager />
|
||||
</div>
|
||||
|
||||
<!-- 音源控制 -->
|
||||
<!-- 更新日志 -->
|
||||
<div
|
||||
v-if="activeTab === 'music-source' && permissions.canAccessPage('music-source')"
|
||||
v-if="activeTab === 'changelog' && permissions.canAccessPage('changelog')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminMusicSourceController />
|
||||
<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>
|
||||
|
||||
<!-- 数据库操作 -->
|
||||
@@ -183,6 +206,14 @@
|
||||
<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')"
|
||||
@@ -190,14 +221,15 @@
|
||||
>
|
||||
<LazyAdminApiKeyManager />
|
||||
</div>
|
||||
<!-- 卡密管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'card-codes' && permissions.canAccessPage('card-codes')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminCardCodesManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminOnboardingTutorial
|
||||
:visible="showOnboardingTutorial"
|
||||
@navigate="handleNavigate"
|
||||
@close="closeOnboardingTutorial"
|
||||
@complete="completeOnboardingTutorial"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
@@ -209,14 +241,12 @@ 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 { useLocale } from '~/utils/locale'
|
||||
import { useScrollMemory } from '~/composables/useScrollMemory'
|
||||
import AdminOnboardingTutorial from '~/components/Admin/AdminOnboardingTutorial.vue'
|
||||
|
||||
// 使用站点配置
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.dashboard || {})
|
||||
|
||||
// 导入组件
|
||||
|
||||
@@ -227,14 +257,16 @@ definePageMeta({
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// 各面板滚动位置独立记忆与恢复
|
||||
useScrollMemory(() => activeTab.value)
|
||||
|
||||
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) => {
|
||||
@@ -252,9 +284,57 @@ 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 = () => {
|
||||
return locale.value.tabs[activeTab.value] || locale.value.fallbackTitle
|
||||
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] || '管理后台'
|
||||
}
|
||||
|
||||
// 动态页面标题
|
||||
@@ -263,7 +343,7 @@ const dynamicTitle = computed(() => {
|
||||
if (siteTitle && siteTitle.value) {
|
||||
return `${currentPageTitle} | ${siteTitle.value}`
|
||||
}
|
||||
return `${currentPageTitle} | ${locale.value.defaultSiteTitle}`
|
||||
return `${currentPageTitle} | 校园广播站点歌系统`
|
||||
})
|
||||
|
||||
// 监听activeTab变化,更新页面标题
|
||||
@@ -288,7 +368,13 @@ watch(
|
||||
)
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
return locale.value.roles[role] || role
|
||||
const roleNames = {
|
||||
USER: '普通用户',
|
||||
SONG_ADMIN: '歌曲管理员',
|
||||
ADMIN: '超级管理员',
|
||||
SUPER_ADMIN: '超级管理员'
|
||||
}
|
||||
return roleNames[role] || role
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -323,6 +409,21 @@ 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) {
|
||||
@@ -332,12 +433,14 @@ const handleResize = () => {
|
||||
|
||||
// 返回顶部功能
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
if (contentScrollRef.value) {
|
||||
contentScrollRef.value.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
showBackToTop.value = window.scrollY > 300
|
||||
showBackToTop.value = (contentScrollRef.value?.scrollTop || 0) > 300
|
||||
}
|
||||
|
||||
// 触摸手势支持
|
||||
@@ -384,6 +487,7 @@ onMounted(async () => {
|
||||
|
||||
// 初始化服务
|
||||
auth = useAuth()
|
||||
await fetchRolesConfig()
|
||||
|
||||
// 检查认证状态(plugin已经初始化过了)
|
||||
|
||||
@@ -413,7 +517,8 @@ onMounted(async () => {
|
||||
|
||||
// 添加窗口大小监听器
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
contentScrollRef.value?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
|
||||
// 添加双击关闭侧边栏事件
|
||||
document.addEventListener('dblclick', (e) => {
|
||||
@@ -421,12 +526,21 @@ onMounted(async () => {
|
||||
closeSidebar()
|
||||
}
|
||||
})
|
||||
|
||||
if (!localStorage.getItem('voicehub_admin_tutorial_seen_v1')) {
|
||||
setTimeout(() => {
|
||||
showOnboardingTutorial.value = true
|
||||
}, 800)
|
||||
}
|
||||
|
||||
await startRevisionPolling()
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
contentScrollRef.value?.removeEventListener('scroll', handleScroll)
|
||||
if (revisionInterval) clearInterval(revisionInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -434,8 +548,8 @@ onUnmounted(() => {
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--panel-bg-deepest);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
color: var(--text-primary, #1a2332);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -447,10 +561,26 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--border-primary, #cbd5e1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
background: var(--border-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
@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>
|
||||
|
||||
+788
-1553
File diff suppressed because it is too large
Load Diff
+30
-28
@@ -4,14 +4,26 @@
|
||||
<div class="form-section">
|
||||
<div class="form-header">
|
||||
<div class="logo-row">
|
||||
<img :src="brandLogoSrc" alt="Brand Logo" class="brand-logo-center" />
|
||||
<div v-if="schoolLogoHomeDisplayUrl" class="logo-divider" />
|
||||
<img
|
||||
v-if="schoolLogoHomeDisplayUrl"
|
||||
:src="schoolLogoHomeDisplayUrl"
|
||||
:alt="locale.schoolLogoAlt"
|
||||
: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" />
|
||||
@@ -29,25 +41,15 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from 'vue'
|
||||
import LoginForm from '~/components/Auth/LoginForm.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useThemeImage } from '~/composables/useThemeImage'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
|
||||
// 使用站点配置
|
||||
const {
|
||||
siteTitle,
|
||||
initSiteConfig,
|
||||
logoUrl,
|
||||
schoolLogoHomeDisplayUrl,
|
||||
icp: icpNumber
|
||||
} = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.login || {})
|
||||
const { siteTitle, initSiteConfig, logoUrl, schoolLogoHomeUrl, icp: icpNumber } = useSiteConfig()
|
||||
// 主品牌Logo优先使用SVG,其次使用站点配置中非ICO的地址
|
||||
const { getLogo } = useThemeImage()
|
||||
const brandLogoSrc = computed(() => {
|
||||
const url = logoUrl.value
|
||||
if (url && !url.endsWith('.ico')) return url
|
||||
return getLogo()
|
||||
return logo
|
||||
})
|
||||
|
||||
// 在组件挂载后初始化站点配置
|
||||
@@ -57,7 +59,7 @@ onMounted(async () => {
|
||||
|
||||
// 设置页面标题
|
||||
if (typeof document !== 'undefined' && siteTitle.value) {
|
||||
document.title = `${locale.value.title} | ${siteTitle.value}`
|
||||
document.title = `登录 | ${siteTitle.value}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -72,7 +74,7 @@ onMounted(async () => {
|
||||
justify-content: flex-start;
|
||||
padding: 20px;
|
||||
/* 响应式尺寸变量 */
|
||||
--color-brand-logo-size: clamp(48px, 8vw, 96px);
|
||||
--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);
|
||||
@@ -109,7 +111,7 @@ onMounted(async () => {
|
||||
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="%23666" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ onMounted(async () => {
|
||||
|
||||
.brand-logo:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
filter: drop-shadow(0 10px 20px var(--mask-25));
|
||||
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@@ -224,13 +226,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.brand-logo-center {
|
||||
width: var(--color-brand-logo-size);
|
||||
height: var(--color-brand-logo-size);
|
||||
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 var(--mask-15));
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.school-logo {
|
||||
@@ -240,7 +242,7 @@ onMounted(async () => {
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
filter: drop-shadow(0 2px 8px var(--mask-15));
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.form-title {
|
||||
@@ -310,7 +312,7 @@ onMounted(async () => {
|
||||
|
||||
.icp-link:hover,
|
||||
.voicehub-link:hover {
|
||||
color: var(--overlay-80);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
Reference in New Issue
Block a user