Files
BYW-VoiceHub/app/pages/index.vue
T
Henry_Du 2ee8056ad1
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 11m43s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 3s
fix(ui): 修复首页强制浅色不生效问题:index.vue 硬编码深色样式变量化
- 根容器 .home 背景 #121318 深色硬编码改为 var(--bg-primary)
- 全站文字 color:#ffffff / rgba(255,255,255,*) 按语义改为 var(--text-primary/secondary/tertiary/quaternary)
- 选项卡、内容面板、下拉菜单、通知卡片、分页按钮等背景/边框统一变量化
- 桌面端和移动端适配样式同步处理,彻底杜绝硬编码深色值绕过 CSS 变量
2026-09-12 13:36:15 +08:00

3681 lines
95 KiB
Vue

<template>
<div class="home">
<div class="ellipse-effect" />
<div class="main-content">
<div class="top-bar">
<div class="logo-section">
<NuxtLink class="logo-link" to="/">
<img
alt="VoiceHub Logo"
class="logo-image"
:src="logo"
width="90"
height="90"
decoding="async"
fetchpriority="high"
>
</NuxtLink>
<!-- 横线和学校logo -->
<div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider-container">
<div class="logo-divider" />
<img
:src="proxiedSchoolLogoUrl"
alt="学校Logo"
class="school-logo"
width="200"
height="80"
decoding="async"
fetchpriority="high"
>
</div>
</div>
<!-- 用户信息区域 -->
<div class="user-section">
<ClientOnly>
<!-- 主题切换按钮 -->
<button
class="utility-btn theme-toggle-btn"
type="button"
aria-label="主题"
title="切换主题"
@click="handleToggleTheme"
>
<Icon :name="isDark ? 'sun' : 'moon'" :size="18" />
<span class="utility-btn-label">主题</span>
</button>
<div v-if="isClientAuthenticated" class="user-info">
<div class="user-details-desktop">
<span class="user-name">{{ user?.name || '用户' }}</span>
<span v-if="isAdmin" class="user-badge admin">{{ roleName }}</span>
<span v-else class="user-badge">{{ userClassInfo }}</span>
</div>
<NuxtLink v-if="isAdmin" class="enter-admin-btn" to="/dashboard">
<Icon name="settings" :size="16" />
<span>进入后台</span>
</NuxtLink>
<div class="user-avatar-wrapper" @click="toggleUserActions">
<img
v-if="user?.avatar && !avatarError"
:src="user.avatar"
class="user-avatar"
@error="avatarError = true"
>
<div v-else class="user-avatar-placeholder">
{{ user?.name?.[0] || 'U' }}
</div>
</div>
<Transition name="dropdown-fade">
<div v-if="showUserActions" class="user-actions-dropdown">
<NuxtLink class="action-item" to="/account">
<Icon name="user" :size="16" />
<span>账号管理</span>
</NuxtLink>
<NuxtLink v-if="isAdmin" class="action-item" to="/dashboard">
<Icon name="settings" :size="16" />
<span>管理后台</span>
</NuxtLink>
<button class="action-item logout" @click="handleLogout">
<Icon name="logout" :size="16" />
<span>退出登录</span>
</button>
</div>
</Transition>
</div>
<div v-else class="login-options">
<NuxtLink class="login-btn" to="/login">
<Icon name="user" :size="16" />
<span>登录/注册</span>
</NuxtLink>
</div>
</ClientOnly>
</div>
</div>
<div v-if="siteTitle" class="site-title">
<div class="title-container">
<h2 class="main-title">{{ siteTitle }}</h2>
<div class="title-divider" />
<span class="sub-title">VoiceHub 校园广播系统</span>
</div>
</div>
<!-- 中间主体内容区域 -->
<div class="content-area">
<!-- 选项卡区域 -->
<div class="tabs-row">
<div
:class="{ active: activeTab === 'schedule' }"
class="section-tab schedule-tab"
@click="handleTabClick('schedule')"
>
<Icon class="tab-icon" name="calendar" :size="20" />
<span class="tab-text">播出排期</span>
</div>
<div
:class="{ active: activeTab === 'songs' }"
class="section-tab songs-tab"
@click="handleTabClick('songs')"
>
<Icon class="tab-icon" name="music" :size="20" />
<span class="tab-text">歌曲列表</span>
</div>
<div
:class="{ active: activeTab === 'request' }"
class="section-tab request-tab"
@click="handleTabClick('request')"
>
<Icon class="tab-icon" name="search" :size="20" />
<span class="tab-text">投稿歌曲</span>
</div>
<ClientOnly>
<div
ref="notificationTabRef"
:class="{ active: activeTab === 'notification', disabled: !isClientAuthenticated }"
class="section-tab"
data-tab="notification"
@click="isClientAuthenticated ? handleTabClick('notification') : showLoginNotice()"
>
<div class="icon-wrapper">
<Icon class="tab-icon" name="message-circle" :size="20" />
<span
v-if="isClientAuthenticated && hasUnreadNotifications"
class="notification-badge-tab"
/>
</div>
<span class="tab-text">
消息
<span
v-if="isClientAuthenticated && hasUnreadNotifications"
class="notification-badge-desktop"
/>
</span>
</div>
<template #fallback>
<div class="section-tab disabled" data-tab="notification">
<Icon class="tab-icon" name="message-circle" :size="20" />
<span class="tab-text">消息</span>
</div>
</template>
</ClientOnly>
</div>
<!-- 内容区域 -->
<div class="tab-content-container">
<ClientOnly>
<!-- 播出排期内容 -->
<div v-if="activeTab === 'schedule'" key="schedule" class="tab-pane schedule-tab-pane">
<div class="full-width">
<SongsScheduleList
:error="error"
:loading="loading"
:schedules="publicSchedules"
@semester-change="handleSemesterChange"
/>
</div>
<!-- 用户使用指南组件 -->
<UserTutorial />
</div>
<!-- 歌曲列表内容 -->
<div v-else-if="activeTab === 'songs'" key="songs" class="tab-pane">
<div class="song-list-container">
<SongsSongList
:error="error"
:is-admin="isAdmin"
:loading="loading"
:songs="filteredSongs"
@refresh="refreshSongs"
@vote="handleVote"
@withdraw="handleWithdraw"
@cancel-replay="handleCancelReplay"
@request-replay="handleRequestReplay"
@semester-change="handleSemesterChange"
/>
</div>
</div>
<!-- 投稿歌曲内容 -->
<div v-else-if="activeTab === 'request'" key="request" class="tab-pane request-pane">
<SongsRequestForm
ref="requestFormRef"
:loading="loading"
@request="handleRequest"
@vote="handleVote"
/>
</div>
<!-- 通知内容 -->
<div
v-else-if="activeTab === 'notification'"
key="notification"
class="tab-pane notification-pane"
>
<div v-if="!isClientAuthenticated" class="login-required-container">
<div class="login-required-content">
<Lock class="login-icon" :size="48" />
<h3>需要登录</h3>
<p>您需要登录才能查看通知</p>
<button class="login-button" @click="navigateToLogin">立即登录</button>
</div>
</div>
<div v-else class="notification-container">
<!-- 标题和设置按钮 -->
<div class="notification-header">
<h2 class="notification-title">通知中心</h2>
<button class="settings-icon" @click="toggleNotificationSettings">
<svg
fill="none"
height="20"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<circle cx="12" cy="12" r="3" />
<path
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
/>
</svg>
</button>
</div>
<!-- 通知列表 -->
<div class="notification-list">
<div v-if="notificationsLoading" class="loading-indicator">
<div class="loading-spinner" />
<span>加载中...</span>
</div>
<div v-else-if="userNotifications.length === 0" class="empty-notification">
<div class="empty-icon">
<Icon :size="48" color="#6b7280" name="bell" />
</div>
<p>暂无通知</p>
</div>
<Transition mode="out-in" name="notification-list-fade">
<div
v-if="userNotifications.length > 0"
:key="notificationsService.currentPage.value"
class="notification-items"
>
<div
v-for="(notification, index) in userNotifications"
:key="notification.id"
:class="{ unread: !notification.read }"
:style="{ '--animation-delay': index * 0.1 + 's' }"
class="notification-card"
@click="viewNotification(notification)"
>
<div class="notification-card-header">
<div class="notification-icon-type">
<Icon
v-if="notification.type === 'SONG_SELECTED'"
:size="20"
color="#4f46e5"
name="check"
/>
<Icon
v-else-if="notification.type === 'SONG_PLAYED'"
:size="20"
color="#10b981"
name="play"
/>
<Icon
v-else-if="notification.type === 'SONG_VOTED'"
:size="20"
color="#f59e0b"
name="thumbs-up"
/>
<Icon
v-else-if="notification.type === 'SONG_REJECTED'"
:size="20"
color="#ef4444"
name="x-circle"
/>
<Icon
v-else-if="notification.type === 'COLLABORATION_INVITE'"
:size="20"
color="#0B5AFE"
name="users"
/>
<Icon
v-else-if="notification.type === 'COLLABORATION_RESPONSE'"
:size="20"
color="#8b5cf6"
name="message-circle"
/>
<Icon v-else :size="20" color="#6b7280" name="bell" />
</div>
<div class="notification-title-row">
<div class="notification-title">
<span v-if="notification.type === 'SONG_SELECTED'">歌曲已选中</span>
<span v-else-if="notification.type === 'SONG_PLAYED'"
>歌曲已播放</span
>
<span v-else-if="notification.type === 'SONG_VOTED'">收到新投票</span>
<span v-else-if="notification.type === 'SONG_REJECTED'"
>歌曲被驳回</span
>
<span v-else-if="notification.type === 'COLLABORATION_INVITE'">
联合投稿邀请
<span
v-if="notification.handled"
:class="[
'status-tag',
notification.status === 'ACCEPTED'
? 'accepted'
: notification.status === 'INVALID'
? 'invalid'
: 'rejected'
]"
>
{{
notification.status === 'ACCEPTED'
? '- 已接受'
: notification.status === 'INVALID'
? '- 已失效'
: '- 已拒绝'
}}
</span>
</span>
<span v-else-if="notification.type === 'COLLABORATION_RESPONSE'"
>联合投稿回复</span
>
<span v-else>系统通知</span>
<span v-if="!notification.read" class="unread-indicator" />
</div>
<div class="notification-time">
{{ formatNotificationTime(notification.createdAt) }}
</div>
</div>
</div>
<div class="notification-card-body">
<div class="notification-text">{{ notification.message }}</div>
<!-- 联合投稿邀请操作按钮 -->
<div
v-if="
notification.type === 'COLLABORATION_INVITE' && !notification.handled
"
class="invite-actions"
>
<button
:disabled="notification.processing"
class="action-button accept-btn"
@click.stop="handleCollaborationReply(notification, true)"
>
{{ notification.processing ? '处理中...' : '接受邀请' }}
</button>
<button
:disabled="notification.processing"
class="action-button reject-btn"
@click.stop="handleCollaborationReply(notification, false)"
>
拒绝
</button>
</div>
</div>
<div class="notification-card-actions">
<button
class="action-button delete"
title="删除"
@click.stop="deleteNotification(notification.id)"
>
<svg
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="3 6 5 6 21 6" />
<path
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
/>
</svg>
<span>删除</span>
</button>
</div>
</div>
</div>
</Transition>
</div>
<!-- 分页控件 -->
<div
v-if="notificationsService.totalCount.value > 0"
class="notification-pagination"
>
<div class="pagination-info">
<span class="pagination-text">
共 {{ notificationsService.totalCount.value }} 条通知, 第
{{ notificationsService.currentPage.value }} /
{{ notificationsService.totalPages.value }} 页
</span>
</div>
<div class="pagination-controls">
<!-- 每页显示数量选择器 -->
<div class="page-size-selector">
<label for="pageSize">每页显示:</label>
<CustomSelect
id="pageSize"
:model-value="notificationsService.pageSize.value"
:options="pageSizeOptions"
class="page-size-custom-select"
@update:model-value="handlePageSizeChange"
/>
</div>
<!-- 页码导航 -->
<div class="page-navigation">
<button
:disabled="
!notificationsService.hasPrevPage.value ||
notificationsService.isPaginationLoading.value
"
class="page-nav-button"
title="上一页"
@click="notificationsService.prevPage()"
>
<svg
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<!-- 页码按钮 -->
<div class="page-numbers">
<template v-for="page in getVisiblePages()" :key="page">
<button
v-if="page !== '...'"
:class="[
'page-number-button',
{ active: page === notificationsService.currentPage.value }
]"
:disabled="notificationsService.isPaginationLoading.value"
@click="notificationsService.goToPage(page)"
>
{{ page }}
</button>
<span v-else class="page-ellipsis">...</span>
</template>
</div>
<button
:disabled="
!notificationsService.hasNextPage.value ||
notificationsService.isPaginationLoading.value
"
class="page-nav-button"
title="下一页"
@click="notificationsService.nextPage()"
>
<svg
fill="none"
height="16"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
</div>
<!-- 分页加载状态 -->
<div
v-if="notificationsService.isPaginationLoading.value"
class="pagination-loading"
>
<div class="loading-spinner" />
<span>加载中...</span>
</div>
</div>
<!-- 底部操作按钮 -->
<div v-if="userNotifications.length > 0" class="notification-actions-bar">
<button
:class="{ disabled: !hasUnreadNotifications }"
:disabled="!hasUnreadNotifications"
class="action-button-large"
@click="markAllNotificationsAsRead"
>
全部标记为已读
</button>
<button class="action-button-large danger" @click="clearAllNotifications">
清空所有消息
</button>
</div>
<!-- 确认对话框 -->
<ConfirmDialog
v-model:show="showConfirmDialog"
:cancel-text="confirmDialogConfig.cancelText"
:confirm-text="confirmDialogConfig.confirmText"
:message="confirmDialogConfig.message"
:title="confirmDialogConfig.title"
:type="confirmDialogConfig.type"
@cancel="handleCancelAction"
@confirm="handleConfirmAction"
/>
</div>
</div>
</ClientOnly>
</div>
</div>
<!-- 页脚信息显示 -->
<SiteFooter />
</div>
<!-- 规则弹窗 -->
<Teleport to="body">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 scale-95"
enter-to-class="opacity-100 scale-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100 scale-100"
leave-to-class="opacity-0 scale-95"
>
<div
v-if="showRules"
class="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
@click.self="showRules = false"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl flex flex-col"
>
<div class="p-8 pb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
>
<Icon name="bell" :size="20" />
</div>
点歌规则
</h3>
<p class="text-xs text-zinc-500 mt-1 ml-13">投稿前请仔细阅读以下规则</p>
</div>
<button
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
@click="showRules = false"
>
<Icon name="x" :size="20" />
</button>
</div>
<div class="p-8 pt-4 space-y-8">
<div class="rules-group space-y-4">
<h4
class="text-[10px] font-black text-zinc-500 uppercase tracking-widest flex items-center gap-2"
>
<Icon name="message-circle" :size="12" />
投稿须知
</h4>
<div
v-if="submissionGuidelines"
class="text-sm text-zinc-400 leading-relaxed font-medium bg-zinc-950/50 p-6 rounded-3xl border border-zinc-800/50"
v-html="submissionGuidelines.replace(/\n/g, '<br>')"
/>
<div
v-else
class="space-y-3 bg-zinc-950/50 p-6 rounded-3xl border border-zinc-800/50"
>
<div class="flex gap-3 text-sm text-zinc-400 font-medium">
<span class="text-blue-500 font-black">01</span>
<p>投稿时无需加入书名号</p>
</div>
<div class="flex gap-3 text-sm text-zinc-400 font-medium">
<span class="text-blue-500 font-black">02</span>
<p>除DJ外,其他类型歌曲均接收(包括小语种)</p>
</div>
<div class="flex gap-3 text-sm text-zinc-400 font-medium">
<span class="text-blue-500 font-black">03</span>
<p>禁止投递含有违规内容的歌曲</p>
</div>
</div>
</div>
<div class="rules-group space-y-4">
<h4
class="text-[10px] font-black text-zinc-500 uppercase tracking-widest flex items-center gap-2"
>
<Icon name="calendar" :size="12" />
播放时间
</h4>
<div
class="bg-blue-600/10 border border-blue-500/20 p-6 rounded-3xl flex items-center gap-4"
>
<div
class="w-12 h-12 rounded-2xl bg-blue-600 flex items-center justify-center text-white shadow-lg shadow-blue-900/40"
>
<Icon name="clock" :size="24" />
</div>
<div>
<p class="text-sm font-black text-zinc-100">每天夜自修静班前</p>
<p class="text-[10px] text-zinc-500 font-bold uppercase tracking-widest mt-0.5">
PLAYBACK TIME
</p>
</div>
</div>
</div>
</div>
<div class="p-8 pt-0">
<button
class="w-full px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg active:scale-95"
@click="showRules = false"
>
我知道了
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<div
v-if="statisticsCodeEnabled && statisticsCode"
id="site-statistics-code"
class="hidden"
v-html="statisticsCode"
/>
</div>
</template>
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Lock } from '@lucide/vue'
import { useRouter } from 'vue-router'
import logo from '~~/public/images/logo.png'
import Icon from '~/components/UI/Icon.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useNotifications } from '~/composables/useNotifications'
import { useSiteConfig } from '~/composables/useSiteConfig'
import { useTheme } from '~/composables/useTheme'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
// 获取运行时配置
const config = useRuntimeConfig()
const router = useRouter()
// 主题
const { isDark, toggleTheme } = useTheme()
const handleToggleTheme = () => {
toggleTheme()
}
// 站点配置
const {
siteTitle,
description: siteDescription,
guidelines: submissionGuidelines,
icp: icpNumber,
schoolLogoHomeUrl,
statisticsCode,
statisticsCodeEnabled,
initSiteConfig
} = useSiteConfig()
const auth = useAuth()
const isClientAuthenticated = computed(() => auth?.isAuthenticated?.value || false)
const isAdmin = computed(() => auth?.isAdmin?.value || false)
const user = computed(() => auth?.user?.value || null)
const roleName = computed(() => {
const role = user.value?.role
const map = {
ADMIN: '部长',
SUPER_ADMIN: '技术运维',
SONG_ADMIN: '工作人员',
VICE_MINISTER: '副部',
RESIDENT: '常驻',
TEACHER: '教师',
USER: '学生(普通用户)'
}
return map[role] || '管理员'
})
const userClassInfo = computed(() => {
if (user.value?.grade && user.value?.class) {
return `${user.value.grade} ${user.value.class}`
}
return '同学'
})
const songs = useSongs()
// 立即初始化通知服务,避免时序问题
const notificationsService = useNotifications()
const unreadNotificationCount = ref(0)
// 模拟数据初始值
const songCount = ref(0)
const scheduleCount = ref(0)
const isRequestOpen = ref(true)
// 弹窗状态
const showRequestModal = ref(false)
const showRules = ref(false)
const showUserActions = ref(false)
const avatarError = ref(false)
const toggleUserActions = (event) => {
event.stopPropagation()
showUserActions.value = !showUserActions.value
}
// 监听用户头像变化,重置错误状态
watch(
() => user.value?.avatar,
() => {
avatarError.value = false
}
)
// 点击外部关闭下拉菜单
const handleClickOutside = (event) => {
if (showUserActions.value) {
const dropdown = document.querySelector('.user-actions-dropdown')
const avatar = document.querySelector('.user-avatar-wrapper')
if (dropdown && !dropdown.contains(event.target) && !avatar.contains(event.target)) {
showUserActions.value = false
}
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
window.removeEventListener('click', handleClickOutside)
})
// 标签页状态
const activeTab = ref('schedule') // 默认显示播出排期
const tabOrder = ['schedule', 'songs', 'request', 'notification']
const activeIndex = computed(() => {
const index = tabOrder.indexOf(activeTab.value)
return index === -1 ? 0 : index
})
// 通知按钮强制更新相关
const notificationTabRef = ref(null)
const hasInitializedAuthData = ref(isClientAuthenticated.value)
let refreshInterval = null
let lastDataRevision = ''
const fetchDataRevision = async () => {
try {
const url = isClientAuthenticated.value ? '/api/songs/revision' : '/api/songs/public-revision'
const authConfig = isClientAuthenticated.value ? auth.getAuthConfig() : {}
const result = await $fetch(url, {
...authConfig,
query: { t: Date.now() }
})
return result?.revision || ''
} catch (e) {
console.error('获取数据修订号失败:', e)
return ''
}
}
const setupRefreshInterval = async () => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
const intervalSeconds = Math.max(5, Math.min(30, notificationSettings.value.refreshInterval || 10))
const intervalMs = intervalSeconds * 1000
lastDataRevision = await fetchDataRevision()
refreshInterval = setInterval(async () => {
try {
const nextRevision = await fetchDataRevision()
if (!nextRevision || nextRevision === lastDataRevision) return
lastDataRevision = nextRevision
if (isClientAuthenticated.value) {
await Promise.allSettled([
songs.fetchSongs(false, undefined, true, true),
songs.fetchPublicSchedules(false, undefined, true, true),
loadNotifications()
])
} else {
await Promise.allSettled([songs.fetchPublicSchedules(false, undefined, true, true), songs.fetchSongCount()])
}
await updateSongCounts()
} catch (error) {
console.error('定期刷新失败:', error)
}
}, intervalMs)
}
// 添加通知相关变量
const userNotifications = computed(() => notificationsService?.notifications?.value || [])
const notificationsLoading = computed(() => notificationsService?.loading?.value || false)
const hasUnreadNotifications = computed(() => {
// 确保notificationsService已初始化且有unreadCount
if (!notificationsService || !notificationsService.unreadCount) {
return false
}
const unreadCount = notificationsService.unreadCount.value
return unreadCount > 0
})
const showNotificationSettings = ref(false)
const pageSizeOptions = [
{ label: '5条', value: 5 },
{ label: '10条', value: 10 },
{ label: '20条', value: 20 },
{ label: '50条', value: 50 }
]
const notificationSettings = ref({
songSelectedNotify: true,
songPlayedNotify: true,
songVotedNotify: true,
songVotedThreshold: 1,
systemNotify: true,
refreshInterval: 60
})
// 跳转到通知设置页面
const toggleNotificationSettings = () => {
navigateTo('/notification-settings')
}
// 获取通知设置
const fetchNotificationSettings = async () => {
if (notificationsService) {
await notificationsService.fetchNotificationSettings()
if (notificationsService.settings.value) {
notificationSettings.value = {
songSelectedNotify: notificationsService.settings.value.songSelectedNotify,
songPlayedNotify: notificationsService.settings.value.songPlayedNotify,
songVotedNotify: notificationsService.settings.value.songVotedNotify,
songVotedThreshold: notificationsService.settings.value.songVotedThreshold || 1,
systemNotify: notificationsService.settings.value.systemNotify,
refreshInterval: notificationsService.settings.value.refreshInterval || 60
}
}
}
}
// 保存通知设置
const saveNotificationSettings = async () => {
if (notificationsService) {
await notificationsService.updateNotificationSettings(notificationSettings.value)
// 如果在首页,更新刷新间隔
if (typeof setupRefreshInterval === 'function') {
setupRefreshInterval()
}
}
}
// 加载通知
const loadNotifications = async () => {
if (isClientAuthenticated.value && notificationsService) {
try {
await notificationsService.fetchNotifications()
} catch (error) {
console.error('[通知获取] 加载通知失败:', error)
}
}
}
// 标记通知为已读
const markNotificationAsRead = async (id) => {
if (notificationsService) {
await notificationsService.markAsRead(id)
}
}
// 标记所有通知为已读
const markAllNotificationsAsRead = async () => {
try {
if (notificationsService) {
await notificationsService.markAllAsRead()
}
} catch (error) {
console.error('[通知操作] 标记所有通知为已读失败:', error)
}
}
// 删除通知
const deleteNotification = async (id) => {
pendingAction.value = 'delete'
pendingId.value = id
confirmDialogConfig.value = {
title: '删除消息',
message: '确定要删除此消息吗?',
type: 'warning',
confirmText: '删除',
cancelText: '取消'
}
showConfirmDialog.value = true
}
// 清空所有通知
const clearAllNotifications = async () => {
pendingAction.value = 'clearAll'
confirmDialogConfig.value = {
title: '清空所有消息',
message: '确定要清空所有消息吗?此操作不可撤销。',
type: 'danger',
confirmText: '清空',
cancelText: '取消'
}
showConfirmDialog.value = true
}
// 确认对话框相关状态
const showConfirmDialog = ref(false)
const confirmDialogConfig = ref({
title: '',
message: '',
type: 'warning',
confirmText: '确定',
cancelText: '取消'
})
const pendingAction = ref('')
const pendingId = ref(null)
// 处理确认操作
const handleConfirmAction = async () => {
if (notificationsService) {
if (pendingAction.value === 'delete') {
await notificationsService.deleteNotification(pendingId.value)
pendingId.value = null
} else if (pendingAction.value === 'clearAll') {
await notificationsService.clearAllNotifications()
}
}
showConfirmDialog.value = false
pendingAction.value = ''
}
// 处理取消操作
const handleCancelAction = () => {
showConfirmDialog.value = false
pendingAction.value = ''
pendingId.value = null
}
// 分页相关方法
const handlePageSizeChange = async (newSize) => {
const size = parseInt(newSize)
if (notificationsService) {
await notificationsService.changePageSize(size)
}
}
// 获取可见的页码列表
const getVisiblePages = () => {
if (!notificationsService) return []
const currentPage = notificationsService.currentPage.value
const totalPages = notificationsService.totalPages.value
const pages = []
if (totalPages <= 7) {
// 总页数少于等于7页,显示所有页码
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
// 总页数大于7页,显示省略号
if (currentPage <= 4) {
// 当前页在前面
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
pages.push('...')
pages.push(totalPages)
} else if (currentPage >= totalPages - 3) {
// 当前页在后面
pages.push(1)
pages.push('...')
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i)
}
} else {
// 当前页在中间
pages.push(1)
pages.push('...')
for (let i = currentPage - 1; i <= currentPage + 1; i++) {
pages.push(i)
}
pages.push('...')
pages.push(totalPages)
}
}
return pages
}
// 格式化通知时间
const formatNotificationTime = (timeString) => {
const date = new Date(timeString)
const now = new Date()
const diff = now.getTime() - date.getTime()
// 小于1分钟
if (diff < 60000) {
return '刚刚'
}
// 小于1小时
if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
}
// 小于24小时
if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`
}
// 小于30天
if (diff < 2592000000) {
return `${Math.floor(diff / 86400000)}天前`
}
// 大于30天,显示具体日期
return `${date.getFullYear()}-${(date.getMonth() + 1).toString().padStart(2, '0')}-${date.getDate().toString().padStart(2, '0')}`
}
// 监听标签页切换,如果切换到通知标签页,加载通知
watch(activeTab, (newTab) => {
if (newTab === 'notification') {
loadNotifications()
}
})
watch(
() => auth?.isAuthenticated?.value,
async (newAuthState, oldAuthState) => {
if (newAuthState && !oldAuthState) {
hasInitializedAuthData.value = true
await Promise.allSettled([loadNotifications(), fetchNotificationSettings(), songs.fetchSongs()])
await updateSongCounts()
return
}
if (!newAuthState && oldAuthState) {
hasInitializedAuthData.value = false
await Promise.allSettled([songs.fetchSongCount(), songs.fetchPublicSchedules()])
unreadNotificationCount.value = 0
await updateSongCounts()
}
},
{ flush: 'post' }
)
// 初始化时如果已经在通知标签页,则加载通知
onMounted(() => {
if (activeTab.value === 'notification') {
loadNotifications()
}
})
// 获取当前日期和星期
const getCurrentDate = () => {
const now = new Date()
const year = now.getFullYear()
const month = now.getMonth() + 1
const date = now.getDate()
const weekDays = ['日', '一', '二', '三', '四', '五', '六']
const weekDay = weekDays[now.getDay()]
return `${year}年${month}月${date}日 周${weekDay}`
}
// RequestForm组件引用
const requestFormRef = ref(null)
// 旧的showNotification函数已移除,使用全局通知系统
// 更新歌曲数量统计(优化版本,避免重复请求)
const updateSongCounts = async (semester = null) => {
try {
// 更新排期歌曲数量
const schedules = songs?.publicSchedules?.value || []
scheduleCount.value = schedules.length
// 更新总歌曲数量
if (isClientAuthenticated.value && songs?.songs?.value) {
// 已登录用户:使用完整歌曲列表
songCount.value = songs.songs.value.length
} else {
// 未登录用户:使用缓存的歌曲总数
songCount.value = songs?.songCount?.value || 0
}
} catch (e) {
console.error('更新歌曲统计失败', e)
}
}
// 监听siteTitle变化,确保首页title正确设置
watch(
siteTitle,
(newSiteTitle) => {
if (typeof document !== 'undefined' && newSiteTitle) {
document.title = `首页 | ${newSiteTitle}`
}
},
{ immediate: true }
)
// 在组件挂载后初始化认证和歌曲(只会在客户端执行)
onMounted(async () => {
try {
await initSiteConfig()
const currentUser = await auth.initAuth()
if (typeof document !== 'undefined' && siteTitle.value) {
document.title = `首页 | ${siteTitle.value}`
}
if (isClientAuthenticated.value) {
hasInitializedAuthData.value = true
await Promise.allSettled([
songs.fetchSongs(),
songs.fetchPublicSchedules(),
loadNotifications(),
fetchNotificationSettings()
])
await checkPasswordChangeRequired(currentUser)
} else {
hasInitializedAuthData.value = false
await Promise.allSettled([songs.fetchSongCount(), songs.fetchPublicSchedules()])
}
await updateSongCounts()
await setupRefreshInterval()
if (songs.notification) {
watch(songs.notification, (newVal) => {
if (newVal.show) {
showNotification(newVal.message, newVal.type)
}
})
}
// 注入站点统计代码(兜底方案:动态插入 script 到 body 末尾)
if (statisticsCodeEnabled.value && statisticsCode.value) {
try {
injectStatisticsCode(statisticsCode.value)
} catch (e) {
console.warn('统计代码注入失败:', e)
}
}
} catch (error) {
console.error('首页初始化失败:', error)
await Promise.allSettled([songs.fetchPublicSchedules(), songs.fetchSongCount()])
await updateSongCounts()
}
})
// 组件卸载时清除定时器
onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
})
// 实时计算歌曲总数
const realSongCount = computed(() => {
return songs?.visibleSongs?.value?.length || 0
})
// 使用计算属性安全地访问数据
const publicSchedules = computed(() => songs?.publicSchedules?.value || [])
const allSongs = computed(() => songs?.visibleSongs?.value || [])
const filteredSongs = computed(() => {
// 返回所有歌曲,但将已播放的歌曲排在最后
if (allSongs.value && allSongs.value.length > 0) {
const unplayedSongs = allSongs.value.filter((song) => !song.played)
const playedSongs = allSongs.value.filter((song) => song.played)
return [...unplayedSongs, ...playedSongs]
}
return []
})
const loading = computed(() => songs?.loading?.value || false)
const error = computed(() => songs?.error?.value || '')
// 处理学校logo的HTTP/HTTPS代理
const proxiedSchoolLogoUrl = computed(() => {
if (!schoolLogoHomeUrl.value || !schoolLogoHomeUrl.value.trim()) {
return ''
}
const logoUrl = schoolLogoHomeUrl.value.trim()
// 如果是HTTP链接,通过代理访问
if (logoUrl.startsWith('http://')) {
return `/api/proxy/image?url=${encodeURIComponent(logoUrl)}`
}
// HTTPS链接或相对路径直接返回
return logoUrl
})
// 处理投稿请求
const handleRequest = async (songData, done) => {
if (!auth || !isClientAuthenticated.value) {
if (window.$showNotification) {
window.$showNotification('需要登录才能投稿歌曲', 'error')
}
showRequestModal.value = false
if (typeof done === 'function') done(false)
return false
}
try {
console.log('处理歌曲请求:', songData.title)
// 直接传递整个songData对象,确保JSON格式正确
const result = await songs.requestSong(songData)
if (result) {
// 显示投稿成功通知
if (window.$showNotification) {
window.$showNotification(`《${songData.title} - ${songData.artist}》投稿成功!`, 'success')
}
// 强制刷新歌曲列表
console.log('投稿成功,刷新歌曲列表')
await refreshSongs()
// 刷新投稿状态
if (requestFormRef.value && requestFormRef.value.refreshSubmissionStatus) {
await requestFormRef.value.refreshSubmissionStatus()
}
// 如果当前在歌曲列表页,自动切换到该页面
if (activeTab.value !== 'songs') {
setTimeout(() => {
handleTabClick('songs')
}, 500)
}
if (typeof done === 'function') done(true)
return true
}
if (typeof done === 'function') done(false)
return false
} catch (err) {
if (window.$showNotification) {
window.$showNotification(err.message || '点歌失败', 'error')
}
if (typeof done === 'function') done(false)
return false
}
}
// 处理投票
const handleVote = async (song) => {
if (!isClientAuthenticated.value) {
showNotification('请先登录后再投票', 'error')
return
}
try {
if (!songs) return
// 调用投票API - 通知已在composable中处理
// 检查是否是取消投票请求
if (song.unvote) {
// 传递完整对象以支持撤销投票功能
await songs.voteSong(song)
} else {
// 保持向后兼容,传递ID
await songs.voteSong(song.id)
}
// 静默刷新歌曲列表以获取最新状态,但不影响当前视图
setTimeout(() => {
songs.refreshSongsSilent().catch((err) => {
console.error('刷新歌曲列表失败', err)
})
}, 500)
} catch (err) {
// 不做任何处理,因为useSongs中已经处理了错误提示
console.log('API错误已在useSongs中处理')
}
}
const handleCancelReplay = async (song) => {
if (!isClientAuthenticated.value) {
showNotification('请先登录才能取消重播申请', 'error')
return
}
try {
if (!songs) return
await songs.withdrawReplay(song.id)
updateSongCounts()
} catch (err) {
console.log('API错误已在useSongs中处理')
}
}
const handleRequestReplay = async (song) => {
if (!isClientAuthenticated.value) {
showNotification('请先登录才能申请重播', 'error')
return
}
try {
if (!songs) return
await songs.requestReplay(song.id)
updateSongCounts()
} catch (err) {
console.log('API错误已在useSongs中处理')
}
}
// 处理撤回投稿
const handleWithdraw = async (song) => {
if (!isClientAuthenticated.value) {
showNotification('请先登录才能撤回投稿', 'error')
return
}
try {
if (!songs) return
// 调用撤回API - 通知已在composable中处理
await songs.withdrawSong(song.id)
// 更新计数
updateSongCounts()
} catch (err) {
// 不做任何处理,因为useSongs中已经处理了错误提示
console.log('API错误已在useSongs中处理')
}
}
// 刷新歌曲列表(优化版本)
const refreshSongs = async () => {
try {
if (isClientAuthenticated.value) {
await songs.fetchSongs(false, undefined, true) // forceRefresh=true
} else {
await songs.fetchPublicSchedules(false, undefined, true) // forceRefresh=true
}
updateSongCounts()
} catch (err) {
console.error('刷新歌曲列表失败', err)
}
}
// 处理学期变化(前端过滤版本)
const handleSemesterChange = async (semester) => {
try {
// 通过事件总线通知SongList组件进行前端过滤
// 使用nextTick确保事件在DOM更新后触发
await nextTick()
// 触发自定义事件,通知所有监听的组件
const event = new CustomEvent('semester-filter-change', {
detail: { semester }
})
window.dispatchEvent(event)
console.log('学期切换事件已发送:', semester)
// 更新歌曲计数(基于当前已有数据)
await updateSongCounts(semester)
} catch (err) {
console.error('切换学期失败', err)
}
}
// 更新通知数量 - 可以保留这个函数但不再调用
const updateNotificationCount = async () => {
// 函数保留但不再使用
}
// 处理登出
const handleLogout = () => {
if (auth) {
auth.logout()
}
}
// 处理进入后台的点击动画
const handleDashboardClick = (event) => {
const button = event.currentTarget
button.classList.add('clicking')
// 添加涟漪效果
const ripple = document.createElement('span')
ripple.classList.add('ripple')
button.appendChild(ripple)
setTimeout(() => {
button.classList.remove('clicking')
ripple.remove()
}, 300)
}
// 添加查看通知并标记为已读
const viewNotification = async (notification) => {
if (!notification.read) {
await notificationsService.markAsRead(notification.id)
}
}
// 处理联合投稿回复
const handleCollaborationReply = async (notification, accept) => {
if (notification.processing) return
notification.processing = true
try {
await $fetch('/api/songs/collaborators/reply', {
method: 'POST',
body: {
songId: notification.songId,
accept
}
})
// 标记为已处理
notification.handled = true
notification.status = accept ? 'ACCEPTED' : 'REJECTED'
notification.repliedAt = new Date()
// notification.message += accept ? ' (已接受)' : ' (已拒绝)'
if (window.$showNotification) {
window.$showNotification(accept ? '已接受联合投稿邀请' : '已拒绝联合投稿邀请', 'success')
}
// 标记通知为已读
await markNotificationAsRead(notification.id)
// 刷新歌曲列表
refreshSongs()
// 刷新通知列表
await loadNotifications()
} catch (error) {
console.error('处理联合投稿邀请失败:', error)
if (window.$showNotification) {
window.$showNotification(error.statusMessage || '操作失败', 'error')
}
} finally {
notification.processing = false
}
}
// 格式化刷新间隔
const formatRefreshInterval = (seconds) => {
if (seconds < 60) {
return `${seconds}秒`
} else {
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
}
}
// 波纹效果指令
const vRipple = {
mounted(el) {
el.addEventListener('click', (e) => {
const rect = el.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const ripple = document.createElement('span')
ripple.className = 'ripple-effect'
ripple.style.left = `${x}px`
ripple.style.top = `${y}px`
el.appendChild(ripple)
setTimeout(() => {
ripple.remove()
}, 600) // 与CSS动画时间一致
})
}
}
// 处理标签点击事件,添加动画效果
const handleTabClick = (tab) => {
activeTab.value = tab
}
// 添加导航到登录页面的方法
const navigateToLogin = () => {
router.push('/login')
}
// 显示登录提示
const showLoginNotice = () => {
if (window.$showNotification) {
window.$showNotification('需要登录才能查看通知', 'info')
}
}
// 注入站点统计代码(兜底方案:动态解析并插入 script 元素到 body 末尾)
const injectStatisticsCode = (code) => {
if (typeof document === 'undefined' || !code) return
// 先尝试用临时容器解析 HTML,提取其中的 script 标签
const tempDiv = document.createElement('div')
tempDiv.innerHTML = code
// 将非 script 的元素先附加到容器中
const container = document.getElementById('site-statistics-code')
if (container) {
Array.from(tempDiv.childNodes).forEach((node) => {
if (node.nodeType === 1 && node.tagName && node.tagName.toLowerCase() !== 'script') {
container.appendChild(node.cloneNode(true))
}
})
}
// 单独处理 script 标签:动态创建并插入到 body 末尾,确保能正确执行
const scripts = tempDiv.querySelectorAll('script')
scripts.forEach((oldScript) => {
const newScript = document.createElement('script')
Array.from(oldScript.attributes).forEach((attr) => {
newScript.setAttribute(attr.name, attr.value)
})
if (oldScript.textContent) {
newScript.textContent = oldScript.textContent
}
document.body.appendChild(newScript)
})
}
// 检查用户是否需要修改密码
const checkPasswordChangeRequired = async (user = null) => {
try {
// 使用传入的用户信息或当前认证状态中的用户信息
const currentUser = user || auth?.user?.value
if (currentUser && currentUser.requirePasswordChange) {
// 延迟1秒显示通知,确保页面加载完成
setTimeout(() => {
if (window.$showNotification) {
window.$showNotification(
'为了您的账户安全,建议您修改密码。您可以点击右上角的"修改密码"按钮进行修改。',
'info',
true,
8000 // 显示8秒
)
}
}, 1000)
}
} catch (error) {
console.error('检查密码修改状态失败:', error)
}
}
// 旧的showToast函数已移除,使用全局通知系统
// 添加未读通知计数
// 之前已声明了unreadNotificationCount,这里对其进行增强
if (
notificationsService &&
notificationsService.unreadCount &&
notificationsService.unreadCount.value
) {
const count = notificationsService.unreadCount.value
unreadNotificationCount.value = count
}
</script>
<style scoped>
.home {
width: 100%;
flex: 1;
background-color: var(--bg-primary, #ffffff);
padding: 1.5rem;
color: var(--text-primary, #1a2332);
display: flex;
flex-direction: column;
min-height: 100vh; /* 确保至少占满视口 */
}
.main-content {
width: 100%;
max-width: 1440px;
margin: 0 auto;
position: relative;
display: flex;
flex-direction: column;
height: 100%; /* 改为占满父容器高度而不是视口高度 */
}
/* 添加顶部Ellipse 1效果 */
.ellipse-effect {
position: absolute;
top: -165px;
left: 50%;
transform: translateX(-50%) perspective(500px) rotateX(10deg);
width: 1110px;
height: 309px;
background: radial-gradient(
ellipse at center,
rgba(11, 90, 254, 0.3) 0%,
rgba(11, 90, 254, 0.15) 30%,
rgba(11, 90, 254, 0) 70%
);
z-index: 0;
pointer-events: none;
}
/* 顶部区域样式 */
.top-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
margin-top: -2rem;
}
.logo-section {
display: flex;
align-items: center;
gap: 20px;
min-height: 160px;
}
.logo-link {
display: block;
text-decoration: none;
}
.logo-image {
width: 80px;
height: auto;
object-fit: contain;
}
/* 横线和学校logo容器 */
.logo-divider-container {
display: flex;
align-items: center;
gap: 30px;
}
/* 横线样式 */
.logo-divider {
width: 2px;
height: 100px;
background: linear-gradient(
to bottom,
var(--border-secondary, #d1d5db),
var(--border-tertiary, #9ca3af),
var(--border-secondary, #d1d5db)
);
border-radius: 1px;
}
/* 学校logo样式 */
.school-logo {
max-width: 200px;
max-height: 80px;
width: auto;
height: auto;
object-fit: contain;
}
.user-section {
position: relative;
z-index: 100;
display: flex;
align-items: center;
gap: 10px;
}
/* 通用工具按钮(主题) */
.utility-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
background: var(--surface-secondary, var(--bg-secondary));
color: var(--text-primary);
border: 1px solid var(--border-primary);
border-radius: 10px;
font-size: 13px;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition:
background 0.2s ease,
color 0.2s ease,
border-color 0.2s ease,
transform 0.15s ease;
}
.utility-btn:hover {
background: var(--surface-tertiary, var(--bg-tertiary));
border-color: var(--border-secondary);
}
.utility-btn:active {
transform: scale(0.97);
}
.utility-btn-label {
white-space: nowrap;
}
@media (max-width: 768px) {
.utility-btn-label {
display: none;
}
.utility-btn {
padding: 7px 8px;
border-radius: 9px;
}
}
.user-info {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
}
.login-options {
display: flex;
align-items: center;
gap: 12px;
}
.enter-admin-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: var(--primary-light, rgba(30, 64, 175, 0.08));
color: var(--primary, #1e40af);
border: 1px solid var(--primary-border, rgba(30, 64, 175, 0.15));
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
transition: all 0.2s ease;
cursor: pointer;
text-decoration: none;
margin-right: 8px;
}
.enter-admin-btn:hover {
background: var(--primary-border, rgba(30, 64, 175, 0.15));
transform: translateY(-1px);
}
.user-details-desktop {
display: flex;
flex-direction: column;
align-items: flex-end;
}
.user-name {
font-size: 14px;
font-weight: 600;
color: var(--text-primary, #1a2332);
}
.user-badge {
font-size: 10px;
padding: 1px 6px;
background: var(--surface-tertiary, var(--bg-tertiary));
color: var(--text-tertiary, #6b7280);
border-radius: 4px;
margin-top: 2px;
}
.user-badge.admin {
background: var(--primary-light, rgba(30, 64, 175, 0.08));
color: var(--primary, #1e40af);
}
.user-avatar-wrapper {
width: 36px;
height: 36px;
border-radius: 50%;
overflow: hidden;
background: var(--surface-secondary, var(--bg-secondary));
border: 1px solid var(--border-primary, #e5e7eb);
display: flex;
align-items: center;
justify-content: center;
}
.user-avatar {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-avatar-placeholder {
font-size: 16px;
font-weight: 700;
color: var(--primary);
}
.user-actions-dropdown {
position: absolute;
top: calc(100% + 12px);
right: 0;
background: var(--surface-primary, var(--bg-primary));
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 12px;
padding: 8px;
min-width: 160px;
box-shadow: var(--shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));
display: flex;
flex-direction: column;
gap: 4px;
}
.action-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: var(--text-secondary, #4a5568);
font-size: 14px;
transition: all 0.2s;
text-decoration: none;
background: transparent;
width: 100%;
text-align: left;
}
.action-item:hover {
background: var(--surface-secondary, var(--bg-secondary));
color: var(--text-primary, #1a2332);
}
.action-item.logout:hover {
background: var(--error-light, rgba(239, 68, 68, 0.1));
color: var(--error, #ef4444);
}
.notification-badge {
position: absolute;
top: -5px;
right: -5px;
min-width: 18px;
height: 18px;
border-radius: 9px;
background-color: var(--error, #ef4444);
color: #ffffff;
font-size: 10px;
font-weight: bold;
display: flex;
align-items: center;
justify-content: center;
padding: 0 4px;
}
/* 登录按钮 - 桌面端 */
.login-options .login-btn {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 24px;
border-radius: 14px;
font-size: 14px;
font-weight: 600;
background: var(--surface-secondary, var(--bg-secondary));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: var(--text-primary, #1a2332);
border: 1px solid var(--border-primary, #e5e7eb);
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
text-decoration: none;
position: relative;
overflow: hidden;
}
.login-options .login-btn:hover {
background: var(--surface-tertiary, var(--bg-tertiary));
border-color: var(--primary-border, rgba(30, 64, 175, 0.15));
transform: translateY(-2px);
box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
color: var(--text-primary, #1a2332);
}
.login-options .login-btn :deep(.icon),
.login-options .login-btn i {
color: var(--primary, #1e40af);
transition: all 0.3s ease;
}
.login-options .login-btn:hover :deep(.icon),
.login-options .login-btn:hover i {
transform: scale(1.1);
filter: drop-shadow(0 0 5px rgba(30, 64, 175, 0.3));
}
.login-options .login-btn:active {
transform: translateY(0) scale(0.96);
transition: all 0.1s;
}
/* 站点标题 */
.site-title {
text-align: center;
margin: 3rem 0;
padding: 0 1rem;
}
.title-container {
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.main-title {
font-family: 'MiSans', sans-serif;
font-weight: 800;
font-size: 42px;
letter-spacing: -0.02em;
background: linear-gradient(135deg, var(--text-primary, #1a2332) 0%, var(--text-secondary, #4a5568) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
line-height: 1.2;
}
.title-divider {
width: 40px;
height: 4px;
background: var(--primary, #1e40af);
border-radius: 2px;
box-shadow: 0 0 15px var(--primary-light, rgba(30, 64, 175, 0.08));
}
.sub-title {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 14px;
color: var(--text-quaternary, #9ca3af);
letter-spacing: 0.2em;
text-transform: uppercase;
}
/* 内容区域结构 */
.content-area {
display: flex;
flex-direction: column;
gap: 0;
flex: 1; /* 占据剩余空间 */
min-height: 0; /* 允许 flex 子元素收缩 */
width: 100%; /* 确保宽度占满 */
}
/* 选项卡样式 - 桌面端 */
.tabs-row {
display: flex;
gap: 5px;
margin-bottom: 0;
overflow-x: auto;
white-space: nowrap;
scrollbar-width: none;
-ms-overflow-style: none;
padding-bottom: 2px;
}
.tabs-row::-webkit-scrollbar {
display: none;
}
.section-tab {
background: var(--surface-secondary, var(--bg-secondary));
border-radius: 15px 15px 0 0;
padding: 15px 24px;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--text-secondary, #4a5568);
border: 2px solid var(--border-primary, #e5e7eb);
border-bottom: none;
cursor: pointer;
flex: 0 0 auto;
}
.section-tab.active {
background: var(--surface-primary, var(--bg-primary));
color: var(--text-primary, #1a2332);
position: relative;
z-index: 1;
}
@keyframes tab-pane-enter {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* 通知列表过渡动画 */
.notification-list-fade-enter-active,
.notification-list-fade-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
.notification-list-fade-enter-from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
.notification-list-fade-leave-to {
opacity: 0;
transform: translateY(-20px) scale(1.02);
}
/* 通知项交错进入动画 */
.notification-card {
animation: notification-item-enter 0.6s cubic-bezier(0.4, 0, 0.2, 1) forwards;
animation-delay: var(--animation-delay, 0s);
opacity: 0;
transform: translateY(20px);
will-change: transform, opacity;
transition:
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s ease;
}
.notification-card:hover {
transform: translateY(-2px) scale(1.02);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
}
@keyframes notification-item-enter {
0% {
opacity: 0;
transform: translateY(20px) scale(0.95);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 选项卡切换动画 */
.section-tab {
position: relative;
transition: all 0.3s ease;
overflow: hidden;
padding: 0.75rem 1.5rem;
z-index: 10; /* 确保在内容之上 */
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.section-tab .tab-icon {
display: none; /* PC端默认隐藏图标 */
}
.section-tab .icon-wrapper {
display: none; /* PC端默认隐藏图标容器,避免间距问题 */
}
.section-tab .tab-text {
display: inline;
position: relative;
}
/* PC端通知小圆点 */
.notification-badge-desktop {
position: absolute;
top: -2px;
right: -8px;
width: 6px;
height: 6px;
background: var(--primary, #1e40af);
border-radius: 50%;
box-shadow: 0 0 5px var(--primary-light, rgba(30, 64, 175, 0.08));
}
.section-tab::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
width: 0;
height: 2px;
background: var(--primary, #1e40af);
transition: all 0.3s ease;
transform: translateX(-50%);
}
.section-tab:hover::after {
width: 50%;
}
.section-tab.active::after {
width: 100%;
}
/* 移除上浮效果 */
.section-tab:hover {
transform: none; /* 移除上浮效果 */
background-color: transparent; /* 移除背景色 */
box-shadow: none; /* 移除内阴影 */
color: var(--text-primary, #1a2332);
}
.section-tab.active:hover {
background-color: transparent;
box-shadow: none;
color: var(--text-primary, #1a2332);
}
/* 内容容器 */
.tab-content-container {
background: var(--surface-primary, var(--bg-primary));
border: 2px solid var(--border-primary, #e5e7eb);
border-radius: 0 15px 15px 15px;
padding: 1.5rem;
margin-top: -2px; /* 使内容容器与标签连接 */
box-sizing: border-box;
width: 100%;
display: flex;
flex-direction: column;
flex: 1; /* 占据剩余空间 */
min-height: 0; /* 允许 flex 子元素收缩 */
overflow: hidden; /* 防止内容溢出 */
}
@media (max-width: 768px) {
.tab-content-container {
padding: 1rem;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
}
.tab-pane {
width: 100%;
box-sizing: border-box;
animation: tab-pane-enter 0.45s ease;
}
/* 针对排期标签页的特殊样式 */
.schedule-tab-pane {
width: 100%;
box-sizing: border-box;
padding: 0;
display: flex;
flex-direction: column;
flex: 1; /* 占满父容器高度 */
min-height: 0; /* 允许 flex 子元素收缩 */
overflow: hidden; /* 防止内容溢出 */
}
.schedule-tab-pane .full-width {
flex: 1; /* 占据剩余空间 */
display: flex;
flex-direction: column;
min-height: 0; /* 允许 flex 子元素收缩 */
overflow: hidden; /* 防止内容溢出 */
}
@media (max-width: 768px) {
.tab-pane {
padding: 0.5rem;
}
.schedule-tab-pane {
padding: 0;
}
/* 移动端分页控件样式 */
.pagination-controls {
flex-direction: column;
gap: 10px;
}
.page-size-selector {
justify-content: center;
}
.page-navigation {
justify-content: center;
flex-wrap: wrap;
}
.page-numbers {
flex-wrap: wrap;
justify-content: center;
}
}
.song-list-container {
width: 100%;
padding: 1rem 0;
}
.date-info p {
font-family: 'MiSans', sans-serif;
font-weight: 400;
font-size: 16px;
letter-spacing: 4%;
}
/* 歌曲时段 */
.time-label {
font-family: 'MiSans', sans-serif;
font-weight: 400;
font-size: 16px;
letter-spacing: 4%;
color: var(--text-tertiary, #6b7280);
margin: 1.5rem 0 1rem;
}
.song-cards {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* 请求表单布局 */
.request-pane {
display: flex;
gap: 2rem;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-tertiary, #6b7280);
}
/* 下拉菜单动画 */
.dropdown-fade-enter-active,
.dropdown-fade-leave-active {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.dropdown-fade-enter-from,
.dropdown-fade-leave-to {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
/* 通知面板 */
.notification-pane {
height: 100%;
display: flex;
flex-direction: column;
}
.notification-container {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
}
/* 通知头部 */
.notification-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 0;
margin-bottom: 1.5rem;
border-bottom: 1px solid var(--border-primary, #e5e7eb);
}
.notification-title {
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary, #1a2332);
margin: 0;
display: flex;
align-items: center;
gap: 0.75rem;
}
.settings-icon {
background: var(--surface-secondary, var(--bg-secondary));
border: 1px solid var(--border-primary, #e5e7eb);
color: var(--text-tertiary, #6b7280);
width: 36px;
height: 36px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.settings-icon:hover {
background-color: var(--surface-tertiary, var(--bg-tertiary));
color: var(--text-primary, #1a2332);
transform: rotate(30deg);
}
/* 通知列表 */
.notification-list {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0.5rem;
margin-bottom: 1.5rem;
min-height: 400px;
}
.loading-indicator,
.empty-notification {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 0;
color: var(--text-quaternary, #9ca3af);
gap: 1.25rem;
}
.empty-icon {
width: 80px;
height: 80px;
background: var(--surface-secondary, var(--bg-secondary));
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 0.5rem;
}
.loading-spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-secondary, #d1d5db);
border-top-color: var(--primary, #1e40af);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.notification-items {
display: flex;
flex-direction: column;
gap: 1rem;
}
.notification-card {
background: var(--surface-secondary, var(--bg-secondary));
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 20px;
padding: 1.25rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
cursor: pointer;
}
.notification-card:hover {
background: var(--surface-tertiary, var(--bg-tertiary));
border-color: var(--border-secondary, #d1d5db);
transform: translateY(-2px);
}
.notification-card.unread {
background: var(--primary-light, rgba(30, 64, 175, 0.08));
border-color: var(--primary-border, rgba(30, 64, 175, 0.15));
}
.notification-card-header {
display: flex;
align-items: flex-start;
margin-bottom: 1rem;
}
.notification-icon-type {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
background: var(--primary-light, rgba(30, 64, 175, 0.08));
color: var(--primary, #1e40af);
border-radius: 12px;
margin-right: 1rem;
flex-shrink: 0;
font-size: 1.25rem;
}
.notification-title-row {
flex: 1;
min-width: 0;
}
.notification-title {
font-size: 0.95rem;
font-weight: 600;
color: var(--text-primary, #1a2332);
margin-bottom: 0.25rem;
}
.notification-time {
font-size: 0.75rem;
color: var(--text-quaternary, #9ca3af);
}
.notification-card-body {
padding: 0 0 0 3.5rem;
}
.notification-text {
color: var(--text-secondary, #4a5568);
font-size: 0.875rem;
line-height: 1.6;
}
.notification-card-actions {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
padding-left: 3.5rem;
}
.action-button.delete {
background: var(--error-light, rgba(239, 68, 68, 0.1));
color: var(--error, #ef4444);
border: 1px solid var(--error-border, rgba(239, 68, 68, 0.2));
padding: 0.4rem 0.75rem;
border-radius: 8px;
font-size: 0.75rem;
display: flex;
align-items: center;
gap: 0.4rem;
transition: all 0.2s;
}
.action-button.delete:hover {
background: rgba(239, 68, 68, 0.15);
border-color: rgba(239, 68, 68, 0.3);
}
.notification-actions-bar {
display: flex;
gap: 1rem;
padding: 1rem 0;
border-top: 1px solid var(--border-primary, #e5e7eb);
}
.action-button-large {
flex: 1;
background: var(--surface-secondary, var(--bg-secondary));
border: 1px solid var(--border-primary, #e5e7eb);
color: var(--text-primary, #1a2332);
padding: 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.action-button-large:hover:not(.disabled) {
background: var(--surface-tertiary, var(--bg-tertiary));
}
.action-button-large.danger {
color: var(--error, #ef4444);
}
.action-button-large.danger:hover {
background: var(--error-light, rgba(239, 68, 68, 0.1));
}
.action-button-large.disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 分页控件样式 */
.notification-pagination {
padding: 15px 0;
border-top: 1px solid var(--border-primary, #e5e7eb);
border-bottom: 1px solid var(--border-primary, #e5e7eb);
}
.pagination-info {
text-align: center;
margin-bottom: 15px;
}
.pagination-text {
color: var(--text-secondary, #4a5568);
font-size: 0.85rem;
}
.pagination-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 15px;
}
.page-size-selector {
display: flex;
align-items: center;
gap: 8px;
}
.page-size-selector label {
color: var(--text-secondary, #4a5568);
font-size: 0.85rem;
white-space: nowrap;
}
.page-size-custom-select {
width: 90px;
}
.page-navigation {
display: flex;
align-items: center;
gap: 8px;
}
.page-nav-button {
background-color: var(--surface-secondary, var(--bg-secondary));
border: 1px solid var(--border-primary, #e5e7eb);
color: var(--text-primary, #1a2332);
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, background-color;
}
.page-nav-button:hover:not(:disabled) {
background-color: var(--surface-tertiary, var(--bg-tertiary));
border-color: var(--border-secondary, #d1d5db);
transform: translateY(-1px);
}
.page-nav-button:active:not(:disabled) {
transform: translateY(0) scale(0.95);
transition: all 0.1s ease;
}
.page-nav-button:disabled {
background-color: var(--surface-secondary, var(--bg-secondary));
border-color: var(--border-primary, #e5e7eb);
color: var(--text-disabled, #c5cbd3);
cursor: not-allowed;
}
.page-numbers {
display: flex;
align-items: center;
gap: 4px;
}
.page-number-button {
background-color: var(--surface-secondary, var(--bg-secondary));
border: 1px solid var(--border-primary, #e5e7eb);
color: var(--text-primary, #1a2332);
width: 32px;
height: 32px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, background-color;
position: relative;
overflow: hidden;
}
.page-number-button:hover:not(:disabled) {
background-color: var(--surface-tertiary, var(--bg-tertiary));
border-color: var(--border-secondary, #d1d5db);
transform: translateY(-1px);
}
.page-number-button:active:not(:disabled) {
transform: translateY(0) scale(0.95);
transition: all 0.1s ease;
}
.page-number-button.active {
background-color: var(--primary);
border-color: var(--primary);
color: var(--btn-primary-text, #ffffff);
}
.page-number-button.active:hover {
background-color: var(--primary-hover, #1e3a8a);
border-color: var(--primary-hover, #1e3a8a);
}
.page-number-button:disabled {
background-color: var(--surface-secondary, var(--bg-secondary));
border-color: var(--border-primary, #e5e7eb);
color: var(--text-disabled, #c5cbd3);
cursor: not-allowed;
}
.page-ellipsis {
color: var(--text-tertiary, #6b7280);
padding: 0 4px;
font-size: 0.85rem;
}
/* 分页加载状态 */
.pagination-loading {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 0;
color: var(--text-secondary, #4a5568);
font-size: 0.85rem;
animation: fade-in 0.3s ease;
}
.loading-spinner {
width: 16px;
height: 16px;
border: 2px solid var(--border-secondary, #d1d5db);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
will-change: transform;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes fade-in {
0% {
opacity: 0;
transform: translateY(10px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* 底部操作栏 */
.notification-actions-bar {
display: flex;
justify-content: center;
gap: 10px;
padding: 15px 0;
border-top: 1px solid var(--border-primary, #e5e7eb);
}
.action-button-large {
background-color: var(--surface-secondary, var(--bg-secondary));
border: none;
color: var(--text-primary, #1a2332);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.action-button-large:hover {
background-color: var(--surface-tertiary, var(--bg-tertiary));
}
.action-button-large.danger {
color: var(--error, #ef4444);
}
.action-button-large.danger:hover {
background-color: var(--error-light, rgba(239, 68, 68, 0.1));
}
.action-button-large.disabled {
background-color: var(--surface-secondary, var(--bg-secondary));
color: var(--text-disabled, #c5cbd3);
cursor: not-allowed;
opacity: 0.5;
}
.action-button-large.disabled:hover {
background-color: var(--surface-secondary, var(--bg-secondary));
}
/* ==================== 移动端设计 ==================== */
/* 基础移动端适配 */
@media (max-width: 768px) {
.home {
padding: 0;
background-color: var(--bg-primary, #ffffff);
}
.main-content {
padding: 0;
}
/* 隐藏原有的ellipse效果,使用更微妙的背景 */
.ellipse-effect {
display: none;
}
/* 顶部区域 */
.top-bar {
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
margin: 0;
background: linear-gradient(180deg, var(--primary-light, rgba(30, 64, 175, 0.08)) 0%, transparent 100%);
border-bottom: 1px solid var(--border-primary, #e5e7eb);
}
.logo-section {
min-height: auto;
gap: 8px;
}
.logo-image {
width: 76px;
height: auto;
}
.logo-divider-container {
gap: 8px;
}
.logo-divider {
height: 28px;
width: 1px;
background: var(--border-secondary, #d1d5db);
}
/* 移动端的主页面里不需要写学校名,保持简洁 */
.site-title {
display: none;
}
.school-logo {
max-width: 120px;
max-height: 36px;
}
/* 用户区域简化 */
.user-section {
width: auto;
}
.user-details-desktop {
display: none; /* 移动端仅显示头像 */
}
.user-avatar-wrapper {
width: 32px;
height: 32px;
background: var(--surface-secondary, var(--bg-secondary));
border-color: var(--border-primary, #e5e7eb);
}
.user-avatar-placeholder {
font-size: 14px;
}
.user-actions-dropdown {
top: calc(100% + 10px);
min-width: 140px;
padding: 6px;
}
.action-item {
padding: 8px 10px;
font-size: 13px;
}
/* 登录按钮 */
.login-options .login-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
background: var(--surface-secondary, var(--bg-secondary));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
color: var(--text-primary, #1a2332);
border: 1px solid var(--border-primary, #e5e7eb);
box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.login-options .login-btn :deep(.icon) {
color: var(--primary, #1e40af);
}
.login-options .login-btn:active {
transform: scale(0.95);
background: var(--surface-tertiary, var(--bg-tertiary));
border-color: var(--primary-border, rgba(30, 64, 175, 0.15));
}
/* Footer 间距优化 */
:deep(.site-footer) {
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
}
/* 内容区域 - 全宽无边框 */
.content-area {
min-height: auto;
overflow-x: hidden; /* 防止横向溢出 */
max-width: 100vw; /* 确保不超过视口宽度 */
box-sizing: border-box; /* 确保padding计入总宽度 */
}
.tabs-row {
position: fixed;
bottom: calc(1rem + env(safe-area-inset-bottom, 0px));
left: 1rem;
right: 1rem;
margin: 0 auto;
max-width: 500px;
display: flex;
justify-content: space-around;
align-items: stretch; /* 修改为 stretch 以配合子元素 height: 100% */
gap: 0;
padding: 0 0.5rem;
height: 64px;
background: var(--surface-primary, var(--bg-primary));
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 9999px;
z-index: 1000;
box-shadow: var(--shadow-xl, 0 20px 25px rgba(0, 0, 0, 0.1));
}
.section-tab {
flex: 1;
height: 100%; /* 确保填满容器高度 */
background: transparent;
border: none;
border-radius: 0;
padding: 0; /* 移除固定内边距,改用 flex 居中 */
font-size: 10px;
font-weight: 500;
color: var(--text-tertiary, #6b7280);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center; /* 垂直居中内容 */
gap: 4px;
position: relative;
transition: all 0.2s ease;
}
.section-tab .tab-icon {
display: block;
margin-bottom: 2px;
transition: all 0.2s ease;
color: currentColor;
}
.section-tab .tab-text {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.02em;
transition: all 0.2s ease;
}
.section-tab.active {
color: var(--primary, #1e40af) !important;
background: transparent !important;
transform: none !important;
}
/* Prevent hover from turning it white on mobile */
.section-tab.active:hover {
color: var(--primary, #1e40af) !important;
background: transparent !important;
box-shadow: none !important;
}
.section-tab.active .tab-icon {
opacity: 1;
color: currentColor;
transform: none;
}
.section-tab.active .tab-text {
font-weight: 700;
}
/* 移除原有的伪元素图标 */
.section-tab::before {
display: none;
}
/* 移除底部指示器(横条)- 彻底隐藏 */
.section-tab::after {
display: none !important;
}
@keyframes dot-pop-in {
0% {
transform: scale(0);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.icon-wrapper {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
margin: 0 auto;
}
.section-tab .icon-wrapper {
display: flex; /* 移动端显示图标容器 */
}
/* 通知徽章 - 回归蓝色风格 */
.notification-badge-tab {
position: absolute;
top: 0;
right: 0;
width: 8px;
height: 8px;
background: var(--primary, #1e40af);
border-radius: 50%;
border: 1.5px solid var(--surface-primary, var(--bg-primary));
box-shadow: 0 0 5px var(--primary-light, rgba(30, 64, 175, 0.08));
z-index: 2;
}
.notification-badge-desktop {
display: none; /* 移动端隐藏桌面版徽章 */
}
@keyframes badge-pulse {
/* 移除导致位移的动画 */
}
.section-tab.disabled {
opacity: 0.3;
filter: grayscale(1);
}
/* 内容容器 */
.tab-content-container {
background: transparent;
border: none;
border-radius: 0;
padding: 0 6px calc(80px + env(safe-area-inset-bottom, 0px));
margin: 0;
min-height: calc(100vh - 120px);
}
.tab-pane {
padding: 0;
overflow-x: hidden; /* 防止横向溢出 */
max-width: 100%; /* 确保不超过视口宽度 */
}
/* 排期标签页优化 */
.schedule-tab-pane {
padding: 0;
min-height: auto;
}
/* 请求表单区域 */
.request-pane {
flex-direction: column;
gap: 16px;
overflow-x: hidden; /* 防止横向溢出 */
max-width: 100%; /* 确保不超过视口宽度 */
padding: 0 0.5rem; /* 添加左右内边距,防止内容贴边 */
box-sizing: border-box; /* 确保padding计入总宽度 */
}
/* 登录选项 */
.login-options {
display: flex;
align-items: center;
}
.login-options .btn-outline {
padding: 6px 14px;
font-size: 12px;
border-radius: 6px;
background: var(--primary-light, rgba(30, 64, 175, 0.08));
border: 1px solid var(--primary-border, rgba(30, 64, 175, 0.15));
}
}
/* 小屏幕设备额外优化 */
@media (max-width: 480px) {
.top-bar {
padding: 8px 10px;
}
.logo-image {
width: 90px;
}
.school-logo {
max-width: 100px;
max-height: 32px;
}
.action-button {
padding: 5px 8px;
font-size: 10px;
}
.site-title {
margin: 6px 0 10px;
padding: 0 12px;
}
.site-title h2 {
font-size: 16px;
}
.tab-content-container {
padding: 0 4px calc(80px + env(safe-area-inset-bottom, 0px));
}
}
/* 超小屏幕设备 */
@media (max-width: 360px) {
.logo-image {
width: 60px;
}
.school-logo {
max-width: 80px;
max-height: 28px;
}
.action-button {
padding: 4px 6px;
font-size: 9px;
}
.section-tab {
font-size: 9px;
}
}
/* 弹窗遮罩层 */
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
animation: overlay-fade-in 0.4s ease-out;
}
@keyframes overlay-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* 弹窗内容 */
.modal-content {
background: linear-gradient(135deg, #1a1b24 0%, #121318 100%);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
max-width: 420px;
width: 90%;
overflow: hidden;
animation: modal-slide-up 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes modal-slide-up {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 弹窗头部 */
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 24px 28px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.modal-header h2 {
font-family:
'MiSans',
system-ui,
-apple-system,
sans-serif;
font-size: 20px;
font-weight: 700;
color: #ffffff;
margin: 0;
letter-spacing: 0.02em;
}
/* 关闭按钮 */
.close-button {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: none;
color: rgba(255, 255, 255, 0.6);
font-size: 24px;
line-height: 1;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.15);
color: #ffffff;
transform: rotate(90deg);
}
/* 弹窗主体 */
.modal-body {
padding: 24px 28px 28px;
}
.rules-container {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.rules-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.rules-subtitle {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 16px;
font-weight: 700;
color: #ffffff;
margin: 0;
}
.rules-icon {
color: #3b82f6;
}
.rules-text {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
margin: 0;
}
.guidelines-content {
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.6;
}
.default-rules {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule-item {
display: flex;
font-size: 14px;
color: rgba(255, 255, 255, 0.6);
line-height: 1.5;
}
.rule-item span {
margin-right: 0.5rem;
color: rgba(255, 255, 255, 0.3);
font-weight: 600;
}
/* 年度报告弹窗 */
.year-review-overlay {
backdrop-filter: blur(8px);
background: rgba(0, 0, 0, 0.4);
}
.year-review-card {
position: relative;
width: 90%;
max-width: 400px;
background: #12121a;
border-radius: 32px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
animation: card-appear 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
.card-glow {
position: absolute;
top: -50%;
left: -50%;
width: 200%;
height: 200%;
background: radial-gradient(circle, rgba(139, 92, 246, 0.15) 0%, transparent 70%);
pointer-events: none;
}
.card-pattern {
position: absolute;
inset: 0;
background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.5;
}
.card-close {
position: absolute;
top: 20px;
right: 20px;
width: 36px;
height: 36px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.4);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s ease;
}
.card-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
transform: translateY(2px);
}
.card-content {
position: relative;
padding: 40px 32px;
text-align: center;
z-index: 5;
}
.brand-badge {
display: inline-block;
padding: 4px 12px;
background: rgba(139, 92, 246, 0.1);
border: 1px solid rgba(139, 92, 246, 0.2);
border-radius: 99px;
color: #a78bfa;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
margin-bottom: 32px;
}
.visual-container {
position: relative;
height: 120px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.main-icon {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #8b5cf6 0%, #6d28d9 100%);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow: 0 10px 25px -5px rgba(139, 92, 246, 0.5);
z-index: 2;
transform: rotate(-5deg);
}
.music-bars {
position: absolute;
display: flex;
align-items: flex-end;
gap: 4px;
height: 40px;
opacity: 0.3;
}
.bar {
width: 4px;
background: #8b5cf6;
border-radius: 2px;
animation: bar-dance 1.2s ease-in-out infinite;
}
.bar:nth-child(1) {
height: 20px;
animation-delay: 0.1s;
}
.bar:nth-child(2) {
height: 35px;
animation-delay: 0.3s;
}
.bar:nth-child(3) {
height: 25px;
animation-delay: 0.2s;
}
.bar:nth-child(4) {
height: 40px;
animation-delay: 0.4s;
}
.bar:nth-child(5) {
height: 30px;
animation-delay: 0.2s;
}
@keyframes bar-dance {
0%,
100% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.6);
}
}
.card-title {
font-size: 24px;
font-weight: 800;
color: #fff;
margin-bottom: 12px;
letter-spacing: -0.01em;
}
.card-description {
font-size: 15px;
color: rgba(255, 255, 255, 0.5);
line-height: 1.6;
margin-bottom: 32px;
}
.card-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn-primary {
width: 100%;
padding: 16px;
background: #fff;
border: none;
border-radius: 16px;
color: #000;
font-size: 16px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.23, 1, 0.32, 1);
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(255, 255, 255, 0.1);
background: #f4f4f4;
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
width: 100%;
padding: 14px;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 16px;
color: rgba(255, 255, 255, 0.6);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.05);
color: #fff;
border-color: rgba(255, 255, 255, 0.2);
}
@keyframes card-appear {
from {
opacity: 0;
transform: translateY(30px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
/* 覆盖旧动画 */
.modal-animation-enter-active,
.modal-animation-leave-active {
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-animation-enter-from,
.modal-animation-leave-to {
opacity: 0;
backdrop-filter: blur(0);
}
.modal-animation-enter-from .year-review-card,
.modal-animation-leave-to .year-review-card {
transform: translateY(40px) scale(0.9);
}
/* 波纹效果 */
.section-tab {
position: relative;
overflow: hidden;
transition: all 0.3s ease;
cursor: pointer;
}
.ripple-effect {
position: absolute;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
transform: scale(0);
animation: ripple 0.6s linear;
pointer-events: none;
width: 100px;
height: 100px;
margin-left: -50px; /* 居中定位 */
margin-top: -50px; /* 居中定位 */
}
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
/* 确保全宽显示 */
.full-width,
.full-width > div {
width: 100% !important;
max-width: 100% !important;
box-sizing: border-box !important;
padding: 0 !important;
margin: 0 !important;
display: block !important;
}
/* 通知标签上的未读徽章 */
.notification-badge-tab {
position: absolute; /* 修改为绝对定位,防止挤压图标 */
top: 0;
right: 0;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #0b5afe;
display: inline-block;
z-index: 2;
}
@keyframes pulse {
0% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 122, 255, 0.4);
}
70% {
transform: scale(1.05);
box-shadow: 0 0 0 5px rgba(0, 122, 255, 0);
}
100% {
transform: scale(1);
box-shadow: 0 0 0 0 rgba(0, 122, 255, 0);
}
}
/* 禁用的标签页样式 */
.section-tab.disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 未登录提示样式 */
.login-required-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px 0;
}
.login-required-content {
text-align: center;
max-width: 400px;
padding: 30px;
background-color: rgba(30, 41, 59, 0.5);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
}
.login-icon {
font-size: 3rem;
margin-bottom: 15px;
}
.login-required-content h3 {
font-size: 1.5rem;
margin-bottom: 10px;
color: var(--light);
}
.login-required-content p {
margin-bottom: 20px;
color: rgba(255, 255, 255, 0.7);
}
.login-button {
background: linear-gradient(180deg, #0043f8 0%, #0075f8 100%);
border: none;
color: white;
padding: 10px 20px;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.login-button:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(0, 67, 248, 0.3);
}
</style>