Files
BYW-VoiceHub/app/pages/index.vue
T
Henry_Du 1a2031325b feat(frontend): 首页信息流与两大核心界面重构
- index.vue: 移除 tabs 单选改为五区块纵向信息流(Hero/排期/歌曲/投稿/消息)
  新增 scrollToSection 平滑滚动与投稿成功自动跳转;为移动端底部导航预留 pb-24
- ScheduleList.vue: 移除左右分栏改为日期横条+时段分组紧凑卡片
  序号采用 28px w900 紫粉蓝渐变 bg-clip-text;卡片三态状态标签色区分
- RequestForm.vue: 序号选择从 pill 升级为 40×40 方格三态(free/taken/selected)
  taken 态删除线+灰色;selected 三色渐变+scale1.05;选中显示「已锁定第N首」提示
2026-09-13 18:39:25 +08:00

3635 lines
94 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="home pb-24 lg:pb-0">
<div class="main-content">
<!-- 右上角浮动用户操作区(简化版,仅保留 user-section) -->
<div class="user-actions-floating">
<div class="user-section">
<ClientOnly>
<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>
<!-- Section Hero -->
<section id="hero-section" class="hero-block">
<div class="hero-left">
<h1 class="hero-title">{{ siteTitle }}</h1>
<p class="hero-subtitle">下周校园,听你的。</p>
<div class="hero-cta">
<button
class="bg-gradient-to-r from-purple-500 via-pink-500 to-blue-500 text-white px-6 py-3 rounded-lg font-bold shadow-lg hover:shadow-xl transition-shadow"
@click="scrollToSection('request-section')"
>
立即点歌
</button>
<button
class="border border-gray-300 text-gray-700 px-6 py-3 rounded-lg font-bold hover:bg-gray-50"
@click="scrollToSection('schedule-section')"
>
查看排期
</button>
</div>
<div class="status-info">
<span>
{{ isRequestOpen ? '当前投稿开放' : '当前投稿关闭' }} · 共 {{ songCount || 0 }} 首投稿 · {{ scheduleCount || 0 }} 首已定排期
</span>
</div>
</div>
<div id="hero-illustration-slot" class="w-full max-w-sm"></div>
</section>
<!-- Section Schedule -->
<section id="schedule-section" class="content-block">
<div class="section-header">
<h2>本周排期</h2>
<span class="sub">播出时间表</span>
</div>
<ClientOnly>
<SongsScheduleList
:error="error"
:loading="loading"
:schedules="publicSchedules"
@semester-change="handleSemesterChange"
/>
</ClientOnly>
</section>
<!-- Section Songs -->
<section id="songs-section" class="content-block">
<div class="section-header">
<h2>热门投稿</h2>
<span class="sub">为喜欢的歌投票</span>
</div>
<ClientOnly>
<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"
/>
</ClientOnly>
</section>
<!-- Section Request -->
<section id="request-section" class="content-block">
<div class="section-header">
<h2>投稿歌曲</h2>
<span class="sub" @click="showRules = true">点歌规则</span>
</div>
<ClientOnly>
<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>
<SongsRequestForm
v-else
ref="requestFormRef"
:loading="loading"
@request="handleRequest"
@vote="handleVote"
/>
</ClientOnly>
</section>
<!-- Section Notification -->
<section id="notification-section" class="content-block">
<div class="section-header">
<h2>我的消息</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>
<ClientOnly>
<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-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>
</ClientOnly>
</section>
<!-- 页脚信息显示 -->
<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 CustomSelect from '~/components/UI/Common/CustomSelect.vue'
// 获取运行时配置
const config = useRuntimeConfig()
const router = useRouter()
// 站点配置
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()
}
// 投稿成功后自动滚动到歌曲列表
setTimeout(() => {
scrollToSection('songs-section')
}, 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动画时间一致
})
}
}
// 处理标签点击事件(@deprecated 保留以防它处引用,页面现已改为纵向区块无标签切换)
const handleTabClick = (tab) => {
// activeTab.value = tab
}
// 滚动到指定区块
const scrollToSection = (id) => {
const el = document.getElementById(id)
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
// 添加导航到登录页面的方法
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;
}
/* 右上角浮动用户操作区 */
.user-actions-floating {
position: relative;
z-index: 100;
display: flex;
justify-content: flex-end;
margin-bottom: 16px;
}
/* 顶部区域样式 */
.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: var(--text-primary, #ffffff);
font-size: 10px;
font-weight: 700;
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: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 20px;
border: 1px solid var(--border-primary, #e5e7eb);
box-shadow: var(--shadow-card, 0 25px 50px -12px rgba(0, 0, 0, 0.12));
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 var(--border-secondary, #f1f5f9);
}
.modal-header h2 {
font-family:
'MiSans',
system-ui,
-apple-system,
sans-serif;
font-size: 20px;
font-weight: 700;
color: var(--text-primary, #1a2332);
margin: 0;
letter-spacing: 0.02em;
}
/* 关闭按钮 */
.close-button {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--surface-tertiary, var(--bg-tertiary, #f1f5f9));
border: none;
color: var(--text-tertiary, #64748b);
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: var(--border-secondary, #e2e8f0);
color: var(--text-primary, #1a2332);
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: var(--text-primary, #1a2332);
margin: 0;
}
.rules-icon {
color: var(--primary, #0b5afe);
}
.rules-text {
font-size: 14px;
color: var(--text-secondary, #4a5568);
line-height: 1.6;
margin: 0;
}
.guidelines-content {
font-size: 14px;
color: var(--text-secondary, #4a5568);
line-height: 1.6;
}
.default-rules {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.rule-item {
display: flex;
font-size: 14px;
color: var(--text-secondary, #4a5568);
line-height: 1.5;
}
.rule-item span {
margin-right: 0.5rem;
color: var(--text-disabled, #94a3b8);
font-weight: 600;
}
/* 年度报告弹窗 */
.year-review-overlay {
backdrop-filter: blur(8px);
background: rgba(15, 23, 42, 0.35);
}
.year-review-card {
position: relative;
width: 90%;
max-width: 400px;
background: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 32px;
overflow: hidden;
border: 1px solid var(--border-primary, #e5e7eb);
box-shadow: var(--shadow-card, 0 25px 50px -12px rgba(0, 0, 0, 0.12));
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(15, 23, 42, 0.06) 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: var(--surface-tertiary, var(--bg-tertiary, #f1f5f9));
border: 1px solid var(--border-secondary, #f1f5f9);
color: var(--text-tertiary, #64748b);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
z-index: 10;
transition: all 0.2s ease;
}
.card-close:hover {
background: var(--border-secondary, #e2e8f0);
color: var(--text-primary, #1a2332);
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.25);
border-radius: 99px;
color: #7c3aed;
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: var(--text-primary, #ffffff);
box-shadow: 0 10px 25px -5px rgba(139, 92, 246, 0.35);
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: var(--text-primary, #1a2332);
margin-bottom: 12px;
letter-spacing: -0.01em;
}
.card-description {
font-size: 15px;
color: var(--text-secondary, #4a5568);
line-height: 1.6;
margin-bottom: 32px;
}
.card-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.btn-primary {
width: 100%;
padding: 16px;
background: var(--primary, #0b5afe);
border: none;
border-radius: 16px;
color: var(--text-primary, #ffffff);
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(11, 90, 254, 0.2);
background: var(--primary-hover, #0043f8);
}
.btn-primary:active {
transform: translateY(0);
}
.btn-secondary {
width: 100%;
padding: 14px;
background: transparent;
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 16px;
color: var(--text-secondary, #4a5568);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.btn-secondary:hover {
background: var(--surface-tertiary, var(--bg-tertiary, #f1f5f9));
color: var(--text-primary, #1a2332);
border-color: var(--border-secondary, #d1d5db);
}
@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(11, 90, 254, 0.25);
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: var(--bg-secondary, #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: var(--surface-secondary, var(--bg-secondary, #f7fafc));
border-radius: 12px;
border: 1px solid var(--border-primary, #e5e7eb);
box-shadow: var(--shadow-card, 0 5px 20px rgba(0, 0, 0, 0.08));
}
.login-icon {
font-size: 3rem;
margin-bottom: 15px;
color: var(--primary, #0b5afe);
}
.login-required-content h3 {
font-size: 1.5rem;
margin-bottom: 10px;
color: var(--text-primary, #1a2332);
}
.login-required-content p {
margin-bottom: 20px;
color: var(--text-secondary, #4a5568);
}
.login-button {
background: linear-gradient(180deg, var(--bg-secondary, #0043f8) 0%, #0075f8 100%);
border: none;
color: var(--text-primary, #ffffff);
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);
}
/* ============ 新增纵向五区块样式 ============ */
.home { width:100%; flex:1; background: var(--bg-primary, #FFFFFF); padding: 0; color: var(--text-primary, #111827); min-height:100vh; }
.main-content { max-width: 1200px; margin: 0 auto; padding: 24px 16px 32px; }
.hero-block { padding: 48px 0 24px; display: flex; flex-direction: column; gap: 32px; }
@media (min-width: 1024px) { .hero-block { flex-direction: row; align-items: center; justify-content: space-between; } }
.hero-title { font-size: 44px; font-weight: 900; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 12px; }
.hero-subtitle { font-size: 18px; color: var(--text-secondary, #4B5563); font-weight: 500; margin-bottom: 24px; }
.hero-cta { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
.status-info { display: inline-flex; align-items: center; gap: 12px; padding: 10px 16px; background: var(--bg-secondary, #F9FAFB); border: 1px solid var(--border-primary, #E5E7EB); border-radius: 8px; font-size: 13px; color: var(--text-secondary); font-weight: 500; }
.content-block { padding: 28px 0; border-top: 1px solid var(--border-primary, #E5E7EB); }
.section-header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 20px; }
.section-header h2 { font-size: 22px; font-weight: 800; letter-spacing: -0.01em; }
.section-header .sub { font-size: 13px; color: var(--text-tertiary, #6B7280); font-weight: 500; cursor: pointer; }
</style>