Files
BYW-VoiceHub/app/components/Songs/ScheduleList.vue
T

3799 lines
98 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="schedule-list">
<!-- 两列布局:左侧日期选择,右侧排期展示 -->
<div class="schedule-container">
<!-- 左侧日期选择列表 -->
<div class="date-selector">
<!-- 移动端日期导航按钮 -->
<div class="mobile-date-nav">
<div class="nav-capsule">
<button :disabled="currentDateIndex === 0" class="nav-btn prev" @click="previousDate">
<Icon :size="18" name="chevron-left" />
</button>
<div class="current-date-display" @click="toggleDatePicker">
<span class="date-text" v-html="currentDateFormatted" />
<Icon :size="12" class="dropdown-icon" name="chevron-down" />
</div>
<button
:disabled="currentDateIndex >= availableDates.length - 1"
class="nav-btn next"
@click="nextDate"
>
<Icon :size="18" name="chevron-right" />
</button>
</div>
<button
v-if="isNeteaseLoggedIn"
class="mobile-action-btn"
type="button"
:title="locale.addToPlaylist"
@click="handleAddToPlaylistClick"
>
<Icon :size="20" color="var(--text-primary)" name="music" />
</button>
</div>
<!-- 移动端日期选择弹窗 -->
<Transition name="date-picker-fade">
<div v-if="showDatePicker" class="date-picker-modal">
<div class="date-picker-overlay" @click="showDatePicker = false" />
<div class="date-picker-content">
<div class="date-picker-header">
<h3>{{ locale.chooseDate }}</h3>
<button class="close-btn" @click="showDatePicker = false">×</button>
</div>
<div class="date-picker-list">
<div
v-for="(date, index) in availableDates"
:key="date"
v-ripple
:class="['date-picker-item', { active: currentDateIndex === index }]"
@click="selectDateAndClose(index)"
v-html="formatDate(date, false)"
/>
<div v-if="availableDates.length === 0" class="empty-dates">{{ locale.noScheduleDates }}</div>
</div>
</div>
</div>
</Transition>
<!-- 桌面端日期列表 -->
<div class="date-list">
<div
v-for="(date, index) in availableDates"
:key="date"
v-ripple
:class="['date-item', { active: currentDateIndex === index }]"
@click="selectDate(index)"
v-html="formatDate(date)"
/>
<div v-if="availableDates.length === 0" class="empty-dates">{{ locale.noScheduleDates }}</div>
</div>
<!-- 添加滚动指示器 -->
<div class="scroll-indicator-container">
<div class="scroll-indicator" />
</div>
</div>
<!-- 分隔线 - 添加径向渐变效果 -->
<div class="vertical-divider" />
<!-- 右侧排期内容 -->
<div class="schedule-content">
<div class="schedule-header">
<h2 class="current-date" v-html="currentDateFormatted" />
<button
v-if="isNeteaseLoggedIn"
class="add-playlist-btn"
type="button"
@click="handleAddToPlaylistClick"
>
<Icon :size="18" color="var(--text-primary)" name="music" />
<span>{{ locale.addToPlaylist }}</span>
</button>
</div>
<!-- 使用Transition组件包裹内容 -->
<Transition mode="out-in" name="schedule-fade">
<div v-if="loading" key="loading" class="loading">
<AppSpinner :size="40" />
{{ locale.loading }}
</div>
<div v-else-if="error" key="error" class="error">
{{ error }}
</div>
<div v-else-if="!schedules || schedules.length === 0" key="empty-all" class="empty">
<div class="icon mb-4">🎵</div>
<p>{{ locale.emptyAll }}</p>
<p class="text-sm text-text-muted">{{ locale.emptyAllDesc }}</p>
</div>
<div v-else-if="currentDateSchedules.length === 0" key="empty-date" class="empty">
<div class="icon mb-4">📅</div>
<p>{{ locale.emptyDate }}</p>
<p>{{ locale.emptyDateDesc }}</p>
</div>
<div v-else :key="currentDate" class="schedule-items">
<!-- 按播出时段分组显示 -->
<template v-if="schedulesByPlayTime && Object.keys(schedulesByPlayTime).length > 0">
<div
v-for="(schedules, playTimeId) in schedulesByPlayTime"
:key="playTimeId"
class="playtime-group"
>
<div v-if="shouldShowPlayTimeHeader(playTimeId)" class="playtime-header">
<h4 v-if="playTimeId === 'null'">{{ locale.unspecifiedPlayTime }}</h4>
<h4 v-else-if="getPlayTimeById(playTimeId)">
{{ getPlayTimeById(playTimeId).name }}
<span
v-if="
getPlayTimeById(playTimeId).startTime || getPlayTimeById(playTimeId).endTime
"
class="playtime-time"
>
({{ formatPlayTimeRange(getPlayTimeById(playTimeId)) }})
</span>
</h4>
</div>
<div class="song-cards">
<div
v-for="schedule in schedules"
:key="schedule.id"
:class="{
played: schedule.song.played && schedule.song.replayRequestCount === 0,
playing: isCurrentPlaying(schedule.song.id)
}"
class="song-card"
>
<div class="song-card-main">
<!-- 歌曲封面 -->
<div class="song-cover" @click="togglePlaySong(schedule.song)">
<template v-if="schedule.song.cover">
<img
:alt="schedule.song.title"
:src="convertToHttps(schedule.song.cover)"
class="cover-image"
referrerpolicy="no-referrer"
@error="handleImageError($event, schedule.song)"
>
</template>
<div v-else class="text-cover">
{{ getFirstChar(schedule.song.title) }}
</div>
<!-- 播放按钮 (仅桌面端显示) -->
<div
v-if="
(schedule.song.musicPlatform && schedule.song.musicId) ||
schedule.song.playUrl
"
class="play-button-overlay"
>
<button
:title="isCurrentPlaying(schedule.song.id) ? locale.pause : locale.play"
class="play-button"
>
<Icon
v-if="isCurrentPlaying(schedule.song.id)"
:size="16"
color="white"
name="pause"
/>
<Icon v-else :size="16" color="white" name="play" />
</button>
</div>
</div>
<div class="song-info">
<h3
:title="schedule.song.title + ' - ' + schedule.song.artist"
class="song-title"
>
<span class="title-text"
>{{ schedule.song.title }} - {{ schedule.song.artist }}</span
>
<!-- 重播标识 -->
<span
v-if="schedule.replayRequestId != null"
class="replay-badge"
:title="locale.replaySong"
>
<Icon name="repeat" :size="14" />
</span>
<!-- 点歌券标识 -->
<span
v-if="schedule.song.usedCardCode"
class="card-code-tag"
:title="locale.cardCodeUsed"
>
{{ locale.cardCodeUsed }}
</span>
<button
v-if="schedule.song?.hasSubmissionNote && schedule.song?.submissionNote"
class="submission-note-trigger"
:title="locale.viewSubmissionNote"
@click.stop="openSubmissionNote(schedule.song)"
>
<Icon :size="14" name="message-circle" />
</button>
</h3>
<div class="song-meta">
<span
v-if="schedule.replayRequestId != null"
:title="
(locale.replayApplicants || '重播申请人:') +
(schedule.song.replayRequesters || [])
.map((r) => r.displayName || r.name)
.join('、')
"
class="requester replay-requester"
>
{{ locale.applicant }}{{
(schedule.song.replayRequesters || [])[0]
? (schedule.song.replayRequesters[0].displayName ||
schedule.song.replayRequesters[0].name) +
(schedule.song.replayRequestCount > 1 ? '...' : '')
: locale.unknown
}}
</span>
<span
v-else
:title="
(schedule.song.collaborators && schedule.song.collaborators.length > 0
? locale.mainRequesterTitle
: locale.requesterTitle) +
schedule.song.requester +
(schedule.song.collaborators && schedule.song.collaborators.length
? '\n' + locale.collaboratorsTitle +
schedule.song.collaborators
.map((c) => c.displayName || c.name)
.join(', ')
: '')
"
class="requester"
>
{{ locale.requester }}{{ schedule.song.requester }}
<span
v-if="
schedule.song.collaborators &&
schedule.song.collaborators.length > 0
"
>
&
{{
schedule.song.collaborators
.map((c) => c.displayName || c.name)
.join(' & ')
}}
</span>
</span>
</div>
</div>
<!-- 人数展示 -->
<div class="action-area">
<div class="vote-count">
<span class="count">{{
schedule.replayRequestId != null
? schedule.song.replayRequestCount
: schedule.song.voteCount
}}</span>
<span class="label">{{
schedule.replayRequestId != null ? locale.replay : locale.heat
}}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</Transition>
</div>
</div>
</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="showPlaylistModal"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
@click.self="closePlaylistModal"
>
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
<div
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
@click.stop
>
<!-- 头部 -->
<div class="flex items-center justify-between p-8 pb-4">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
>
<Icon name="music" :size="24" />
</div>
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.addToPlaylist }}</h3>
</div>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
type="button"
@click="closePlaylistModal"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
<div
v-if="!isNeteaseLoggedIn"
class="flex flex-col items-center justify-center py-20 text-center"
>
<div
class="w-20 h-20 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-6"
>
<Icon name="music" :size="40" class="text-text-tertiary opacity-20" />
</div>
<p class="text-text-tertiary font-medium mb-8">{{ locale.loginRequiredForPlaylist }}</p>
<button
class="px-10 py-4 rounded-2xl bg-primary-hover hover:bg-primary text-text-primary font-black transition-all active:scale-95 shadow-xl shadow-[var(--primary-glow)]"
type="button"
@click="openLoginFromPlaylist"
>
{{ locale.loginNow }}
</button>
</div>
<div v-else class="space-y-8">
<!-- 用户信息栏 -->
<div
v-if="neteaseUser"
class="flex items-center p-4 bg-bg-tertiary-30 border border-border-secondary-50 rounded-2xl"
>
<div
class="w-12 h-12 rounded-xl overflow-hidden bg-bg-tertiary mr-4 ring-2 ring-panel-bg-hover-50"
>
<img
v-if="neteaseUser.avatarUrl"
:src="neteaseUser.avatarUrl"
alt="avatar"
class="w-full h-full object-cover"
>
<Icon v-else name="user" :size="24" class="w-full h-full p-3 text-text-tertiary" />
</div>
<div class="flex-1 min-w-0">
<span
class="block text-[10px] font-black text-text-tertiary uppercase tracking-widest mb-0.5"
>{{ locale.currentAccount }}</span
>
<span class="block font-bold text-text-primary truncate">
{{ neteaseUser.nickname || neteaseUser.userName || locale.neteaseUser }}
</span>
</div>
</div>
<!-- 歌单操作区域 -->
<div class="space-y-6">
<!-- 选择歌单 -->
<div class="space-y-3">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.targetPlaylist }}</label
>
<div class="flex gap-3">
<CustomSelect
v-model="selectedPlaylistId"
:options="formattedPlaylists"
label-key="displayName"
value-key="id"
:placeholder="locale.playlistPlaceholder"
class="flex-1"
/>
<button
:disabled="playlistsLoading"
class="w-10 h-[38px] flex items-center justify-center rounded-lg bg-bg-primary border border-border-secondary text-text-tertiary hover:text-text-primary hover:border-border-tertiary transition-all disabled:opacity-50"
:title="locale.refreshPlaylist"
type="button"
@click="reloadPlaylists"
>
<Icon
name="refresh"
:size="16"
:class="{ 'animate-spin': playlistsLoading }"
/>
</button>
</div>
<div v-if="selectedPlaylistId" class="px-1 pt-1">
<button
:disabled="playlistActionLoading"
class="text-[10px] font-black text-error-60 hover:text-error flex items-center gap-1.5 transition-colors uppercase tracking-wider"
type="button"
@click="handleDeletePlaylist"
>
<Icon name="trash" :size="14" />
{{ locale.deletePlaylist }}
</button>
</div>
</div>
<div class="relative py-2 flex items-center justify-center">
<div class="absolute inset-0 flex items-center px-8">
<div class="w-full border-t border-border-secondary-30" />
</div>
<span
class="relative px-4 bg-bg-secondary text-[10px] font-black text-text-disabled uppercase tracking-[0.2em]"
>{{ locale.or }}</span
>
</div>
<!-- 创建新歌单 -->
<div class="space-y-4">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest ml-1"
>{{ locale.createPlaylist }}</label
>
<div class="flex gap-3">
<input
v-model="newPlaylistName"
class="flex-1 px-5 py-3.5 bg-bg-primary border border-border-secondary rounded-xl text-text-primary text-sm placeholder-text-disabled focus:outline-none focus:border-primary-30 transition-all"
:placeholder="locale.newPlaylistPlaceholder"
type="text"
>
<button
:disabled="!newPlaylistName.trim() || playlistActionLoading"
class="px-8 py-3.5 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
type="button"
@click="handleCreatePlaylist"
>
{{ playlistActionLoading ? '...' : locale.create }}
</button>
</div>
<label class="flex items-center gap-3 cursor-pointer group w-fit ml-1">
<div class="relative">
<input v-model="newPlaylistPrivacy" class="sr-only peer" type="checkbox" >
<div
class="w-9 h-5 bg-bg-tertiary rounded-full border border-border-tertiary peer-checked:bg-primary-hover peer-checked:border-primary transition-all"
/>
<div
class="absolute left-1 top-1 w-3 h-3 bg-bg-quaternary rounded-full transition-all peer-checked:left-5 peer-checked:bg-bg-secondary"
/>
</div>
<span
class="text-[10px] font-black text-text-tertiary uppercase tracking-widest group-hover:text-text-secondary transition-colors"
>{{ locale.privatePlaylist }}</span
>
</label>
</div>
</div>
<!-- 歌曲选择区域 -->
<div class="space-y-4">
<div class="flex items-center justify-between px-1">
<label class="text-[10px] font-black text-text-tertiary uppercase tracking-widest">
{{ locale.selectSongs }}
<span
class="ml-2 px-2 py-0.5 rounded-md bg-primary-hover-10 text-primary text-[9px]"
>{{ selectedSongIds.length }} / {{ neteaseSongs.length }}</span
>
</label>
<div class="flex gap-4">
<button
class="text-[10px] font-black text-text-tertiary hover:text-primary uppercase tracking-wider transition-colors"
type="button"
@click="selectAllNeteaseSongs"
>
{{ locale.selectAll }}
</button>
<button
class="text-[10px] font-black text-text-tertiary hover:text-error uppercase tracking-wider transition-colors"
type="button"
@click="clearSelectedSongs"
>
{{ locale.clear }}
</button>
</div>
</div>
<div
v-if="neteaseSongs.length === 0"
class="flex flex-col items-center justify-center py-12 bg-bg-primary-30 border border-dashed border-border-secondary rounded-3xl text-text-disabled"
>
<Icon name="music" :size="32" class="mb-3 opacity-20" />
<p class="text-[10px] font-black uppercase tracking-widest">
{{ locale.noNeteaseSongs }}
</p>
</div>
<div v-else class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
v-for="song in neteaseSongs"
:key="song.id"
:class="[
'group flex items-center p-3.5 rounded-xl border transition-all cursor-pointer',
isSongSelected(song.id)
? 'bg-primary-hover-10 border-primary-30 shadow-lg'
: 'bg-bg-primary border-transparent hover:border-border-secondary'
]"
@click="toggleSongSelection(song.id)"
>
<div
:class="[
'w-5 h-5 rounded-lg border-2 flex items-center justify-center mr-3.5 transition-all',
isSongSelected(song.id)
? 'bg-primary-hover border-primary text-text-primary'
: 'border-border-secondary group-hover:border-border-tertiary'
]"
>
<Icon v-if="isSongSelected(song.id)" name="check" :size="12" />
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold truncate text-text-primary">
{{ song.title }}
</div>
<div
class="text-[10px] font-black uppercase tracking-widest truncate mt-0.5 text-text-tertiary"
>
{{ song.artist }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 底部操作栏 -->
<div v-if="isNeteaseLoggedIn" class="p-8 pt-0">
<div class="flex gap-3">
<button
class="flex-1 px-6 py-4 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
type="button"
@click="closePlaylistModal"
>
{{ locale.cancel }}
</button>
<button
:disabled="
!selectedPlaylistId || selectedSongIds.length === 0 || playlistActionLoading
"
class="flex-[2] px-6 py-4 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 shadow-lg shadow-[var(--primary-glow)] flex items-center justify-center gap-2 uppercase tracking-widest"
type="button"
@click="handleAddSongsToPlaylist"
>
<Icon v-if="playlistActionLoading" name="loader" :size="16" class="animate-spin" />
<Icon v-else name="plus" :size="16" />
<span>{{ playlistActionLoading ? locale.adding : locale.confirmAdd }}</span>
</button>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
<Teleport to="body">
<ConfirmDialog
:loading="playlistActionLoading"
:message="confirmDialog.message"
:show="confirmDialog.show"
:title="confirmDialog.title"
:type="confirmDialog.type"
@cancel="closeConfirmDialog"
@close="closeConfirmDialog"
@confirm="handleConfirmAction"
/>
</Teleport>
<Teleport to="body">
<NeteaseLoginModal
v-if="showLoginModal"
:show="showLoginModal"
@close="showLoginModal = false"
@login-success="handleLoginSuccess"
/>
</Teleport>
<Teleport to="body">
<Transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="submissionNoteDialog.show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-60 backdrop-blur-sm"
@click="closeSubmissionNote"
>
<div class="submission-note-modal" @click.stop>
<div class="submission-note-header">
<h4>{{ locale.submissionNote }}</h4>
<button @click="closeSubmissionNote">
<Icon :size="14" name="close" />
</button>
</div>
<div class="submission-note-meta">
<span class="song-title-tag">{{ submissionNoteDialog.songTitle }}</span>
<span :class="['visibility-tag', submissionNoteDialog.isPublic ? 'visibility-public' : 'visibility-private']">
{{ submissionNoteDialog.isPublic ? locale.publicNote : locale.privateNote }}
</span>
</div>
<div class="submission-note-content-box">
<p class="submission-note-content">{{ submissionNoteDialog.note }}</p>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Music, X, User, RefreshCw, Trash2, Check, Plus, Loader2 } from '@lucide/vue'
import { useSongs } from '~/composables/useSongs'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { useAudioQuality } from '~/composables/useAudioQuality'
import Icon from '~/components/UI/Icon.vue'
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { convertToHttps } from '~/utils/url'
import { isBilibiliSong } from '~/utils/bilibiliSource'
import { getMusicUrl as resolveMusicUrl } from '~/utils/musicUrl'
import { useLocale } from '~/utils/locale'
import NeteaseLoginModal from './NeteaseLoginModal.vue'
import {
addSongsToPlaylist,
createPlaylist,
deletePlaylist,
getUserPlaylists
} from '~/utils/neteaseApi'
const props = defineProps({
schedules: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
}
})
// 音频播放相关 - 使用全局音频播放器
const audioPlayer = useAudioPlayer()
const { checkNeteaseLoginStatus: updateGlobalNeteaseStatus, persistNeteaseVipFlag } = useAudioQuality()
const { currentLocale, songs: songsLocale } = useLocale()
const locale = computed(() => songsLocale.value?.scheduleList || {})
const { t: callLocale } = useLocaleText(locale)
// 获取播放时段启用状态
const { playTimeEnabled } = useSongs()
// 确保schedules不为null
const safeSchedules = computed(() => props.schedules || [])
// 日期选择器状态
const showDatePicker = ref(false)
const showPlaylistModal = ref(false)
const isNeteaseLoggedIn = ref(false)
const neteaseUser = ref(null)
const neteaseCookie = ref('')
const playlists = ref([])
const playlistsLoading = ref(false)
const formattedPlaylists = computed(() => {
return playlists.value.map((pl) => ({
...pl,
displayName: `${pl.name} (${callLocale('songsCount', '', pl.trackCount)})`
}))
})
const selectedPlaylistId = ref('')
const playlistActionLoading = ref(false)
const selectedSongIds = ref([])
const newPlaylistName = ref('')
const newPlaylistPrivacy = ref(false)
const showLoginModal = ref(false)
const confirmDialog = ref({
show: false,
title: '',
message: '',
type: 'warning',
onConfirm: null
})
const submissionNoteDialog = ref({
show: false,
songTitle: '',
note: '',
isPublic: false
})
const isInitialized = ref(false)
const lastSelectedDate = ref('')
// 按日期分组排期
const safeGroupedSchedules = computed(() => {
const groups = {}
if (!safeSchedules.value || !safeSchedules.value.length) {
return {}
}
safeSchedules.value.forEach((schedule) => {
if (!schedule || !schedule.playDate) return
try {
// 使用UTC时间处理日期
const scheduleDate = new Date(schedule.playDate)
const date = `${scheduleDate.getFullYear()}-${String(scheduleDate.getMonth() + 1).padStart(2, '0')}-${String(scheduleDate.getDate()).padStart(2, '0')}`
if (!groups[date]) {
groups[date] = []
}
groups[date].push(schedule)
} catch (err) {
// 无需在此处记录错误
}
})
// 按日期排序
const sortedGroups = {}
Object.keys(groups)
.sort()
.forEach((date) => {
sortedGroups[date] = groups[date]
})
return sortedGroups
})
// 日期导航
const availableDates = computed(() => {
return Object.keys(safeGroupedSchedules.value).sort()
})
const currentDateIndex = ref(0)
// 当前显示的日期
const currentDate = computed(() => {
if (availableDates.value.length === 0) return ''
return availableDates.value[currentDateIndex.value]
})
// 当日期列表变化时切换到今天日期
watch(
availableDates,
(newDates) => {
if (newDates.length > 0) {
// 如果已经初始化过且有上次选中的日期,尝试保持
if (isInitialized.value && lastSelectedDate.value) {
const index = newDates.indexOf(lastSelectedDate.value)
if (index !== -1) {
currentDateIndex.value = index
return
}
}
// 否则(首次加载或选中日期不存在),执行自动跳转逻辑
findAndSelectTodayOrClosestDate()
}
},
{ immediate: false }
)
// 自动滚动到指定日期项的函数
const scrollToDateItem = async (index) => {
if (isMobile.value) return // 移动端不需要滚动日期列表
await nextTick() // 等待DOM更新
const dateList = document.querySelector('.date-list')
const dateItems = document.querySelectorAll('.date-item')
if (!dateList || !dateItems || index >= dateItems.length) return
const targetItem = dateItems[index]
const listRect = dateList.getBoundingClientRect()
const itemRect = targetItem.getBoundingClientRect()
// 计算目标位置,使选中项居中显示
const listCenter = listRect.height / 2
const itemCenter = itemRect.height / 2
const scrollTop = dateList.scrollTop + (itemRect.top - listRect.top) - listCenter + itemCenter
// 平滑滚动到目标位置
dateList.scrollTo({
top: Math.max(0, scrollTop),
behavior: 'smooth'
})
}
// 统一解析 YYYY-MM-DD 格式的本地日期
const parseLocalDateParts = (dateStr) => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateStr)
if (!match) return null
const year = Number(match[1])
const month = Number(match[2])
const day = Number(match[3])
const date = new Date(year, month - 1, day)
if (
!Number.isFinite(year) ||
!Number.isFinite(month) ||
!Number.isFinite(day) ||
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null
}
return { year, month, day }
}
// 提取日期选择逻辑到独立函数
const toLocalMidnightTimestamp = (dateStr) => {
const parsedDate = parseLocalDateParts(dateStr)
if (!parsedDate) return null
const { year, month, day } = parsedDate
return new Date(year, month - 1, day, 0, 0, 0, 0).getTime()
}
const findAndSelectTodayOrClosestDate = async () => {
if (availableDates.value.length === 0) return
const todayStr = getBeijingTimeISOString().split('T')[0]
let selectedIndex = 0
// 统一逻辑:所有设备先尝试直接字符串匹配找"今天"
const todayIndex = availableDates.value.findIndex((date) => date === todayStr)
if (todayIndex >= 0) {
// 如果找到今天的日期,则选择它
selectedIndex = todayIndex
} else {
// 如果今天没有排期,用时间戳找到最接近今天的日期
// 使用午夜时间戳来比较,避免时间部分的影响
const todayMidnightTime = toLocalMidnightTimestamp(todayStr)
if (todayMidnightTime === null) return
let closestFutureIndex = -1
let minFutureDiff = Number.MAX_SAFE_INTEGER
// 查找今天或之后最近的日期
availableDates.value.forEach((dateStr, index) => {
const dateTime = toLocalMidnightTimestamp(dateStr)
if (dateTime === null) return
const diff = dateTime - todayMidnightTime
// 优先选择今天或未来的日期
if (diff >= 0 && diff < minFutureDiff) {
minFutureDiff = diff
closestFutureIndex = index
}
})
// 如果找到了今天或未来的日期,选择它
if (closestFutureIndex >= 0) {
selectedIndex = closestFutureIndex
} else {
// 如果没有今天或未来的日期,选择最近的过去日期
let closestPastIndex = -1
let minPastDiff = Number.MAX_SAFE_INTEGER
availableDates.value.forEach((dateStr, index) => {
const dateTime = toLocalMidnightTimestamp(dateStr)
if (dateTime === null) return
const diff = todayMidnightTime - dateTime
if (diff > 0 && diff < minPastDiff) {
minPastDiff = diff
closestPastIndex = index
}
})
if (closestPastIndex >= 0) {
selectedIndex = closestPastIndex
}
}
}
// 设置选中的日期索引
currentDateIndex.value = selectedIndex
// 标记为已初始化并保存选中日期
isInitialized.value = true
lastSelectedDate.value = availableDates.value[selectedIndex]
// 自动滚动到选中的日期项
await scrollToDateItem(selectedIndex)
}
// 格式化当前日期
const currentDateFormatted = computed(() => {
if (!currentDate.value) return locale.value.emptyDate
return formatDate(currentDate.value, isMobile.value)
})
// 当前日期的排期
const currentDateSchedules = computed(() => {
if (!currentDate.value) return []
return safeGroupedSchedules.value[currentDate.value] || []
})
const neteaseSongs = computed(() => {
if (!currentDateSchedules.value || currentDateSchedules.value.length === 0) return []
const map = new Map()
for (const schedule of currentDateSchedules.value) {
const song = schedule.song
if (!song) continue
const platform = song.musicPlatform
const source = song.sourceInfo && song.sourceInfo.source
const musicId = song.musicId
if (!musicId) continue
const isNetease = platform === 'netease' || source === 'netease-backup'
if (!isNetease) continue
if (!map.has(song.id)) {
map.set(song.id, song)
}
}
return Array.from(map.values())
})
// 上一个日期
const previousDate = async () => {
if (currentDateIndex.value > 0) {
currentDateIndex.value--
// 在桌面端自动滚动到新选中的日期
if (!isMobile.value) {
await scrollToDateItem(currentDateIndex.value)
}
}
}
// 下一个日期
const nextDate = async () => {
if (currentDateIndex.value < availableDates.value.length - 1) {
currentDateIndex.value++
// 在桌面端自动滚动到新选中的日期
if (!isMobile.value) {
await scrollToDateItem(currentDateIndex.value)
}
}
}
// 选择特定日期
const selectDate = async (index) => {
currentDateIndex.value = index
lastSelectedDate.value = availableDates.value[index]
showDatePicker.value = false
// 自动滚动到选中的日期项
await scrollToDateItem(index)
}
// 切换日期选择器显示状态
const toggleDatePicker = async () => {
showDatePicker.value = !showDatePicker.value
// 如果弹窗打开,自动滚动到当前选中的日期
if (showDatePicker.value) {
await nextTick() // 等待DOM渲染完成
scrollToSelectedDateInModal()
}
}
// 在移动端弹窗中滚动到选中的日期项
const scrollToSelectedDateInModal = () => {
const modalList = document.querySelector('.date-picker-list')
const modalItems = document.querySelectorAll('.date-picker-item')
if (!modalList || !modalItems || currentDateIndex.value >= modalItems.length) return
const targetItem = modalItems[currentDateIndex.value]
const listRect = modalList.getBoundingClientRect()
const itemRect = targetItem.getBoundingClientRect()
// 计算目标位置,使选中项在可视区域内,并增加向下偏移
const listCenter = listRect.height / 2
const itemCenter = itemRect.height / 2
const downwardOffset = 280
const scrollTop =
modalList.scrollTop + (itemRect.top - listRect.top) - listCenter + itemCenter + downwardOffset
// 确保滚动位置不会超出边界
const maxScrollTop = modalList.scrollHeight - modalList.clientHeight
const finalScrollTop = Math.max(0, Math.min(scrollTop, maxScrollTop))
// 平滑滚动到目标位置
modalList.scrollTo({
top: finalScrollTop,
behavior: 'smooth'
})
}
// 选择日期并关闭弹窗
const selectDateAndClose = (index) => {
currentDateIndex.value = index
lastSelectedDate.value = availableDates.value[index]
showDatePicker.value = false
}
// 重置日期到第一天
const resetDate = () => {
currentDateIndex.value = 0
}
// 格式化日期
const formatDate = (dateStr, isMobile = false) => {
try {
const parsedDate = parseLocalDateParts(dateStr)
if (!parsedDate) {
throw new Error(locale.value.invalidDateFormat)
}
const { year, month, day } = parsedDate
// 创建 UTC 日期对象,避免浏览器本地时区导致排期日期跨天。
const date = new Date(Date.UTC(year, month - 1, day))
// 检查日期是否有效
if (isNaN(date.getTime())) {
throw new Error(locale.value.invalidDate)
}
const weekday = new Intl.DateTimeFormat(currentLocale.value, {
weekday: 'short',
timeZone: 'UTC'
}).format(date)
// 移动端显示更紧凑的格式
if (isMobile) {
const formattedMobileDate = new Intl.DateTimeFormat(currentLocale.value, {
month: 'short',
day: 'numeric',
timeZone: 'UTC'
}).format(date)
return callLocale('mobileDate', formattedMobileDate, month, day, weekday, formattedMobileDate)
}
const intlFormattedDate = new Intl.DateTimeFormat(currentLocale.value, {
year: 'numeric',
month: 'short',
day: 'numeric',
timeZone: 'UTC'
}).format(date)
const formattedDate = callLocale('fullDate', intlFormattedDate, year, month, day, intlFormattedDate)
return `${formattedDate}\n<span class="weekday">${weekday}</span>`
} catch (e) {
return dateStr || locale.value.unknown
}
}
// 添加窗口大小变化监听
let resizeTimer = null
const isMobile = ref(window.innerWidth <= 768)
// 定义窗口大小变化处理函数
const handleResize = () => {
clearTimeout(resizeTimer)
resizeTimer = setTimeout(async () => {
const wasMobile = isMobile.value
isMobile.value = window.innerWidth <= 768
// 如果从移动端切换到桌面端,需要重新滚动到当前选中的日期
if (wasMobile && !isMobile.value && availableDates.value.length > 0) {
await nextTick()
await scrollToDateItem(currentDateIndex.value)
}
}, 100)
}
// 监听窗口大小变化
onMounted(async () => {
window.addEventListener('resize', handleResize)
// 初始化移动状态
isMobile.value = window.innerWidth <= 768
// 寻找今天的日期并自动选择 - 初始加载时也尝试一次
findAndSelectTodayOrClosestDate()
checkNeteaseLoginStatus()
})
// 组件销毁前移除事件监听器
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
})
// 处理图片加载错误
const handleImageError = (event, song) => {
if (event?.target) {
event.target.style.display = 'none'
if (event.target.parentNode) {
event.target.parentNode.classList.add('text-cover')
event.target.parentNode.textContent = getFirstChar(song.title)
}
}
}
// 获取歌曲标题的第一个字符作为封面
const getFirstChar = (title) => {
if (!title) return (locale.value.unknown || '音').slice(0, 1)
return title.trim().charAt(0)
}
const checkNeteaseLoginStatus = () => {
if (typeof window === 'undefined') return
const cookie = localStorage.getItem('netease_cookie')
const userStr = localStorage.getItem('netease_user')
if (cookie) {
neteaseCookie.value = cookie
isNeteaseLoggedIn.value = true
// 同步全局网易云登录状态
updateGlobalNeteaseStatus()
if (userStr) {
try {
neteaseUser.value = JSON.parse(userStr)
} catch (e) {
neteaseUser.value = null
}
}
} else {
neteaseCookie.value = ''
neteaseUser.value = null
isNeteaseLoggedIn.value = false
// 同步全局网易云登录状态
updateGlobalNeteaseStatus()
}
}
const handleLoginSuccess = (data) => {
neteaseCookie.value = data.cookie
neteaseUser.value = data.user
isNeteaseLoggedIn.value = true
if (typeof window !== 'undefined') {
localStorage.setItem('netease_cookie', data.cookie)
localStorage.setItem('netease_user', JSON.stringify(data.user))
// 同步 VIP 标志(vipType 非 0 即 VIP)
persistNeteaseVipFlag(data.user)
// 更新全局网易云登录状态,通知其他组件(如AudioPlayer)
updateGlobalNeteaseStatus()
}
showLoginModal.value = false
if (showPlaylistModal.value) {
reloadPlaylists()
}
}
const handleAddToPlaylistClick = () => {
if (!isNeteaseLoggedIn.value) {
showLoginModal.value = true
return
}
if (!neteaseSongs.value.length) {
if (window.$showNotification) {
window.$showNotification(locale.value.noNeteaseSongs, 'warning')
}
return
}
selectedSongIds.value = neteaseSongs.value.map((song) => song.id)
showPlaylistModal.value = true
reloadPlaylists()
}
const closePlaylistModal = () => {
showPlaylistModal.value = false
}
const openLoginFromPlaylist = () => {
showLoginModal.value = true
}
const reloadPlaylists = async () => {
if (!isNeteaseLoggedIn.value || !neteaseCookie.value || !neteaseUser.value) return
const uid = neteaseUser.value.userId || neteaseUser.value.id
if (!uid) return
playlistsLoading.value = true
try {
const { code, message, body } = await getUserPlaylists(uid, neteaseCookie.value)
const list = body && Array.isArray(body.playlist) ? body.playlist : []
if (code === 200 && Array.isArray(list)) {
playlists.value = list
if (!selectedPlaylistId.value && playlists.value.length > 0) {
selectedPlaylistId.value = playlists.value[0].id
}
} else {
if (window.$showNotification) {
const text = message
? callLocale('playlistLoadFailedWithMessage', locale.value?.playlistLoadFailed || '播放列表加载失败', message)
: locale.value.playlistLoadFailed
window.$showNotification(text, 'error')
}
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification(locale.value.playlistLoadFailed, 'error')
}
} finally {
playlistsLoading.value = false
}
}
const handleCreatePlaylist = async () => {
const name = newPlaylistName.value.trim()
if (!name) return
if (!isNeteaseLoggedIn.value || !neteaseCookie.value) return
playlistActionLoading.value = true
try {
const { code, message, body } = await createPlaylist(
name,
newPlaylistPrivacy.value,
neteaseCookie.value
)
if (code === 200) {
const createdId = body && (body.id || (body.playlist && body.playlist.id))
if (window.$showNotification) {
window.$showNotification(locale.value.playlistCreateSuccess, 'success')
}
newPlaylistName.value = ''
await reloadPlaylists()
if (createdId) {
selectedPlaylistId.value = createdId
}
} else {
if (window.$showNotification) {
const text = message
? callLocale('playlistCreateFailedWithMessage', locale.value?.playlistCreateFailed || '播放列表创建失败', message)
: locale.value.playlistCreateFailed
window.$showNotification(text, 'error')
}
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification(locale.value.playlistCreateFailed, 'error')
}
} finally {
playlistActionLoading.value = false
}
}
const handleDeletePlaylist = async () => {
if (!selectedPlaylistId.value) return
if (!isNeteaseLoggedIn.value || !neteaseCookie.value) return
confirmDialog.value = {
show: true,
title: locale.value.deletePlaylistTitle,
message: locale.value.deletePlaylistMessage,
type: 'danger',
onConfirm: async () => {
playlistActionLoading.value = true
try {
const { code, message } = await deletePlaylist(
selectedPlaylistId.value,
neteaseCookie.value
)
if (code === 200) {
if (window.$showNotification) {
window.$showNotification(locale.value.playlistDeleteSuccess, 'success')
}
await reloadPlaylists()
if (
!playlists.value.find((pl) => pl.id === selectedPlaylistId.value) &&
playlists.value.length > 0
) {
selectedPlaylistId.value = playlists.value[0].id
}
closeConfirmDialog()
} else {
if (window.$showNotification) {
const text = message
? callLocale('playlistDeleteFailedWithMessage', locale.value?.playlistDeleteFailed || '播放列表删除失败', message)
: locale.value.playlistDeleteFailed
window.$showNotification(text, 'error')
}
// 失败也关闭弹窗,或者保留让用户重试?通常关闭比较好,避免死循环
closeConfirmDialog()
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification(locale.value.playlistDeleteFailed, 'error')
}
closeConfirmDialog()
} finally {
playlistActionLoading.value = false
}
}
}
}
const closeConfirmDialog = () => {
confirmDialog.value.show = false
// 延迟清除回调,防止动画期间触发
setTimeout(() => {
confirmDialog.value.onConfirm = null
}, 300)
}
const openSubmissionNote = (song) => {
if (!song?.submissionNote) return
submissionNoteDialog.value = {
show: true,
songTitle: `${song.title || locale.value.unknown} - ${song.artist || locale.value.unknown}`,
note: song.submissionNote,
isPublic: song.submissionNotePublic === true
}
}
const closeSubmissionNote = () => {
submissionNoteDialog.value.show = false
}
const handleConfirmAction = () => {
if (confirmDialog.value.onConfirm) {
confirmDialog.value.onConfirm()
}
}
const isSongSelected = (songId) => {
return selectedSongIds.value.includes(songId)
}
const toggleSongSelection = (songId) => {
const index = selectedSongIds.value.indexOf(songId)
if (index === -1) {
selectedSongIds.value.push(songId)
} else {
selectedSongIds.value.splice(index, 1)
}
}
const selectAllNeteaseSongs = () => {
selectedSongIds.value = neteaseSongs.value.map((song) => song.id)
}
const clearSelectedSongs = () => {
selectedSongIds.value = []
}
const handleAddSongsToPlaylist = async () => {
if (!selectedPlaylistId.value) return
if (!isNeteaseLoggedIn.value || !neteaseCookie.value) return
const tracks = neteaseSongs.value
.filter((song) => selectedSongIds.value.includes(song.id))
.map((song) => song.musicId)
.filter((id) => !!id)
if (!tracks.length) {
if (window.$showNotification) {
window.$showNotification(locale.value.selectSongs, 'warning')
}
return
}
playlistActionLoading.value = true
try {
const { code, message } = await addSongsToPlaylist(
selectedPlaylistId.value,
tracks,
neteaseCookie.value
)
if (code === 200) {
if (window.$showNotification) {
window.$showNotification(callLocale('playlistAddSuccess', '', tracks.length), 'success')
}
showPlaylistModal.value = false
} else {
if (window.$showNotification) {
const text = message
? callLocale('playlistAddFailedWithMessage', locale.value?.playlistAddFailed || '添加到播放列表失败', message)
: locale.value.playlistAddFailed
window.$showNotification(text, 'error')
}
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification(locale.value.playlistAddFailed, 'error')
}
} finally {
playlistActionLoading.value = false
}
}
// 切换歌曲播放/暂停
const togglePlaySong = async (song) => {
// 检查是否为当前歌曲且正在播放
if (audioPlayer.isCurrentSong(song.id) && audioPlayer.getPlayingStatus().value) {
// 如果正在播放,则暂停
audioPlayer.pauseSong()
return
}
// 如果是当前歌曲但已暂停,则恢复播放
if (audioPlayer.isCurrentSong(song.id) && !audioPlayer.getPlayingStatus().value) {
// 检查当前全局歌曲是否有URL
const currentGlobalSong = audioPlayer.getCurrentSong().value
if (
currentGlobalSong &&
(currentGlobalSong.musicUrl || isBilibiliSong(currentGlobalSong))
) {
// 如果有URL或者是哔哩哔哩视频,直接恢复播放
audioPlayer.playSong(currentGlobalSong)
} else {
// 如果没有URL,重新获取
if ((song.musicPlatform && song.musicId) || song.playUrl) {
try {
const url = await getMusicUrl(song)
if (url || isBilibiliSong(song)) {
const playableSong = {
...song,
musicUrl: url || null
}
audioPlayer.playSong(playableSong)
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.musicUrlUnavailable, 'error')
}
}
} catch (error) {
console.error('获取音乐URL失败:', error)
if (window.$showNotification) {
window.$showNotification(locale.value.musicUrlFailed, 'error')
}
}
}
}
return
}
// 如果有平台和ID信息或playUrl,动态获取URL
if ((song.musicPlatform && song.musicId) || song.playUrl) {
const buildPlaylistForSong = (targetSong) => {
const currentTimeSlot = getCurrentTimeSlot(targetSong)
let playlist = []
let songIndex = 0
if (currentTimeSlot && currentTimeSlot.songs) {
playlist = currentTimeSlot.songs.map((s) => ({
id: s.id,
title: s.title,
artist: s.artist,
cover: s.cover,
musicUrl: s.musicUrl || null,
musicPlatform: s.musicPlatform,
musicId: s.musicId,
playUrl: s.playUrl || null,
sourceInfo: s.sourceInfo
}))
songIndex = playlist.findIndex((s) => s.id === targetSong.id)
if (songIndex === -1) songIndex = 0
}
return { playlist, songIndex }
}
try {
const url = await getMusicUrl(song)
if (url || isBilibiliSong(song)) {
const { playlist, songIndex } = buildPlaylistForSong(song)
const playableSong = {
...song,
musicUrl: url || null
}
if (playlist.length > 0 && songIndex >= 0) {
playlist[songIndex] = playableSong
}
// 后台预取后续歌曲的播放链接(不阻塞当前播放)
;(async () => {
for (let i = songIndex + 1; i < playlist.length; i++) {
const s = playlist[i]
if (!s.musicUrl && ((s.musicPlatform && s.musicId) || s.playUrl)) {
try {
s.musicUrl = await getMusicUrl(s)
} catch (error) {
console.warn(`后台预取失败: ${s.title}`, error)
s.musicUrl = null
}
}
}
})()
audioPlayer.playSong(playableSong, playlist, songIndex)
} else {
if (window.$showNotification) {
window.$showNotification(locale.value.musicUrlUnavailable, 'error')
}
audioPlayer.playSong({ ...song, musicUrl: null }, [], -1)
}
} catch (error) {
console.error('获取音乐URL失败:', error)
if (window.$showNotification) {
window.$showNotification(locale.value.musicUrlFailed, 'error')
}
const { playlist, songIndex } = buildPlaylistForSong(song)
audioPlayer.playSong({ ...song, musicUrl: null }, playlist, songIndex)
}
}
}
// 获取歌曲所在的时段
const getCurrentTimeSlot = (song) => {
if (!schedulesByPlayTime.value) return null
for (const [playTimeId, schedules] of Object.entries(schedulesByPlayTime.value)) {
if (schedules.some((schedule) => schedule.song.id === song.id)) {
return {
id: playTimeId,
songs: schedules.map((schedule) => schedule.song)
}
}
}
return null
}
// 动态获取音乐URL
const getMusicUrl = async (song) => {
const { musicPlatform: platform, musicId, playUrl, sourceInfo } = song
// 如果有自定义播放链接,优先使用
if (playUrl && playUrl.trim()) {
console.log(`[ScheduleList] 使用自定义播放链接: ${playUrl}`)
return playUrl.trim()
}
// 如果没有playUrl,检查platform和musicId是否有效
if (!platform || !musicId) {
throw new Error(locale.value.missingMusicInfo)
}
// 检查是否为播客内容
const isPodcast =
platform === 'netease-podcast' ||
sourceInfo?.type === 'voice' ||
(sourceInfo?.source === 'netease-backup' && sourceInfo?.type === 'voice')
const options = {
unblock: isPodcast ? false : undefined,
mediaId: sourceInfo?.strMediaMid || sourceInfo?.mediaId || sourceInfo?.mediaMid,
musicInfo: {
name: song.title,
artist: song.artist,
album: song.album || undefined
}
}
return resolveMusicUrl(platform, musicId, undefined, options)
}
// 判断当前是否正在播放指定ID的歌曲
const isCurrentPlaying = (songId) => {
return audioPlayer.isCurrentPlaying(songId)
}
// 格式化播放时间
const formatPlayTime = (schedule) => {
try {
// 根据歌曲播放状态显示不同文本
if (schedule.song && schedule.song.played) {
return locale.value.played
} else {
return locale.value.scheduled
}
} catch (e) {
return locale.value.unspecifiedPlayTime
}
}
// 按播出时段分组的排期
const schedulesByPlayTime = computed(() => {
if (!currentDateSchedules.value || currentDateSchedules.value.length === 0) {
return null
}
const grouped = {}
// 先对排期按时段和序号排序
const sortedSchedules = [...currentDateSchedules.value].sort((a, b) => {
// 先按时段分组,确保转换为字符串
const playTimeIdA =
a.playTimeId !== null && a.playTimeId !== undefined ? String(a.playTimeId) : 'null'
const playTimeIdB =
b.playTimeId !== null && b.playTimeId !== undefined ? String(b.playTimeId) : 'null'
if (playTimeIdA !== playTimeIdB) {
// 未指定时段排在最后
if (playTimeIdA === 'null') return 1
if (playTimeIdB === 'null') return -1
// 使用数字比较而不是字符串比较
return parseInt(playTimeIdA) - parseInt(playTimeIdB)
}
// 时段相同则按序号排序
return a.sequence - b.sequence
})
// 分组
for (const schedule of sortedSchedules) {
// 确保正确处理播放时段ID
const playTimeId =
schedule.playTimeId !== null && schedule.playTimeId !== undefined
? String(schedule.playTimeId)
: 'null'
if (!grouped[playTimeId]) {
grouped[playTimeId] = []
}
grouped[playTimeId].push(schedule)
}
return grouped
})
// 根据ID获取播出时段信息
const getPlayTimeById = (id) => {
if (id === 'null') return null
try {
const numId = parseInt(id)
if (isNaN(numId)) return null
// 从排期中查找
for (const schedule of currentDateSchedules.value) {
// 确保正确比较
if (schedule.playTimeId === numId && schedule.playTime) {
return schedule.playTime
}
}
} catch (err) {
// 无需在此处记录错误
}
return null
}
// 格式化播出时段时间范围
const formatPlayTimeRange = (playTime) => {
if (!playTime) return ''
if (playTime.startTime && playTime.endTime) {
return `${playTime.startTime} - ${playTime.endTime}`
} else if (playTime.startTime) {
return callLocale('playTimeStart', playTime.startTime, playTime.startTime)
} else if (playTime.endTime) {
return callLocale('playTimeEnd', playTime.endTime, playTime.endTime)
}
return locale.value.unspecifiedPlayTime
}
// 判断是否显示播放时段标题
const shouldShowPlayTimeHeader = (playTimeId) => {
// 如果播放时段功能未启用且是未指定时段,则不显示
if (!playTimeEnabled.value && playTimeId === 'null') {
return false
}
return true // 显示其他所有时段
}
// 波纹效果指令
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动画时间一致
})
}
}
</script>
<style scoped>
.schedule-list {
width: 100% !important;
position: relative;
box-sizing: border-box;
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
display: flex;
flex-direction: column;
flex: 1; /* 占满父容器高度 */
min-height: 0; /* 允许 flex 子元素收缩 */
}
/* 学期选择器样式 */
.semester-selector {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, var(--primary-light) 0%, var(--panel-bg-deep) 100%);
border-radius: 12px;
border: 1px solid var(--primary-border);
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px var(--mask-15);
}
.semester-label {
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
margin-right: 0.75rem;
white-space: nowrap;
text-shadow: 0 1px 2px var(--mask-30);
}
.semester-select {
background: linear-gradient(135deg, var(--overlay-15) 0%, var(--overlay-8) 100%);
border: 1px solid var(--overlay-25);
border-radius: 8px;
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
min-width: 180px;
backdrop-filter: blur(5px);
box-shadow: 0 2px 8px var(--mask-10);
}
.semester-select:hover {
background: linear-gradient(135deg, var(--color-accent-alpha-20) 0%, var(--overlay-15) 100%);
border-color: var(--color-accent-alpha-60);
transform: translateY(-1px);
box-shadow: 0 4px 12px var(--color-accent-alpha-20);
}
.semester-select:focus {
outline: none;
border-color: var(--color-accent);
box-shadow:
0 0 0 3px var(--color-accent-alpha-30),
0 4px 12px var(--color-accent-alpha-20);
transform: translateY(-1px);
}
.semester-select option {
background: var(--panel-bg-overlay);
color: var(--text-primary);
padding: 0.5rem;
}
/* 两列布局容器 */
.schedule-container {
display: flex;
gap: 0; /* 移除间隙,使用分隔线 */
width: 100% !important;
box-sizing: border-box;
margin: 0 !important;
padding: 0 !important;
max-width: none !important;
flex: 1; /* 占据剩余空间 */
min-height: 0; /* 允许 flex 子元素收缩 */
}
/* 左侧日期选择器 */
.date-selector {
width: 200px;
flex-shrink: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%; /* 占满容器高度 */
min-height: 0; /* 允许 flex 子元素收缩 */
}
.date-list {
flex: 1; /* 占据剩余空间 */
overflow-y: auto;
width: 100%;
min-height: 0; /* 允许 flex 子元素收缩 */
}
/* 增强日期项目样式 */
.date-item {
padding: 0.8rem 1rem;
font-family: 'MiSans', sans-serif;
font-weight: 400;
font-size: 14px;
color: var(--overlay-80);
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 0.5rem;
border-radius: 8px;
white-space: pre-line;
text-align: left;
line-height: 1.4;
position: relative;
overflow: hidden;
}
.date-item:hover {
background: var(--panel-bg);
transform: translateY(-2px);
box-shadow: 0 2px 8px var(--mask-20);
}
.date-item.active {
background: var(--panel-bg);
color: var(--text-primary);
font-weight: 600;
border-radius: 8px;
box-shadow: 0 4px 12px var(--color-accent-alpha-20);
border-left: 3px solid var(--color-accent);
}
.empty-dates {
padding: 2rem 1rem;
text-align: center;
color: var(--overlay-60);
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: center;
min-height: 100px;
}
.weekday {
display: block;
font-size: 12px;
opacity: 0.7;
margin-top: 2px;
}
/* 垂直分隔线 */
.vertical-divider {
width: 2px;
background: linear-gradient(
180deg,
transparent 0%,
var(--schedule-list-divider) 50%,
transparent 100%
);
margin: 0 1.5rem;
position: relative;
align-self: stretch; /* 占满父容器高度 */
flex-shrink: 0;
}
/* 右侧排期内容 */
.schedule-content {
flex: 1;
min-width: 0;
max-width: calc(100% - 250px); /* 缩小右侧内容区域宽度 */
display: flex;
flex-direction: column;
height: 100%; /* 占满容器高度 */
min-height: 0; /* 允许 flex 子元素收缩 */
}
.schedule-header {
margin-bottom: 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-shrink: 0; /* 防止被压缩 */
}
.current-date {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 20px;
color: var(--text-primary);
margin: 0;
}
/* 加载和错误状态 */
.loading {
padding: 3rem;
text-align: center;
border-radius: 10px;
background: var(--panel-bg);
margin: 1rem 0;
color: var(--overlay-60);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error,
.empty {
padding: 2rem;
text-align: center;
border-radius: 10px;
background: var(--panel-bg);
margin: 1rem 0;
color: var(--overlay-60);
}
.error {
color: var(--color-error);
}
.empty .icon {
font-size: 3rem;
opacity: 0.5;
}
/* 排期内容区域 */
.schedule-items {
flex: 1; /* 占据剩余空间 */
overflow-y: auto; /* 允许滚动 */
min-height: 0; /* 允许 flex 子元素收缩 */
}
/* 排期时段分组 */
.playtime-group {
margin-bottom: 2rem;
}
.playtime-header h4 {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--overlay-60);
margin: 0 0 1rem 0;
}
.playtime-time {
font-size: 14px;
color: var(--overlay-40);
margin-left: 0.5rem;
}
/* 歌曲卡片样式 */
.song-cards {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.song-card {
width: 320px;
flex-shrink: 0;
background: var(--panel-bg);
border-radius: 10px;
overflow: hidden;
position: relative;
}
/* ----------------------------------
Add playlist button
---------------------------------- */
.add-playlist-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: 1px solid var(--overlay-10);
background: var(--overlay-10);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
backdrop-filter: blur(10px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.add-playlist-btn:hover {
background: var(--overlay-20);
border-color: var(--overlay-20);
}
.add-playlist-btn:active {
transform: translateY(0);
}
.add-playlist-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* ----------------------------------
Playlist modal
---------------------------------- */
.playlist-modal-overlay {
position: fixed;
inset: 0;
background: var(--mask-75);
backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.playlist-modal {
width: 100%;
max-width: 580px;
max-height: 85vh;
background: var(--panel-bg-elevated);
border-radius: 20px;
box-shadow: 0 25px 50px -12px var(--mask-50);
border: 1px solid var(--overlay-8);
display: flex;
flex-direction: column;
overflow: hidden;
animation: modal-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
/* 头部样式 */
.playlist-modal-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--overlay-8);
display: flex;
align-items: center;
justify-content: space-between;
background: var(--panel-bg-overlay);
}
.header-title {
display: flex;
align-items: center;
gap: 0.75rem;
}
.header-icon {
color: var(--primary);
}
.playlist-modal-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
letter-spacing: 0.5px;
}
.playlist-modal-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: var(--overlay-5);
color: var(--overlay-60);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.playlist-modal-close:hover {
background: var(--overlay-10);
color: var(--text-primary);
transform: rotate(90deg);
}
/* 内容区域 */
.playlist-modal-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background: var(--panel-bg-elevated);
}
/* 滚动条美化 */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: var(--overlay-10);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: var(--overlay-20);
}
/* 登录提示 */
.login-prompt-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem 1rem;
text-align: center;
}
.login-icon-wrapper {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, var(--primary-10) 0%, var(--primary-5) 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
color: var(--primary);
}
.login-hint {
color: var(--overlay-70);
margin-bottom: 2rem;
font-size: 15px;
}
.full-width {
width: 100%;
justify-content: center;
padding: 0.8rem;
font-size: 15px;
}
/* 用户信息栏 */
.user-profile-bar {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
background: var(--surface-subtle);
border-radius: 12px;
margin-bottom: 1.5rem;
border: 1px solid var(--overlay-5);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--panel-bg-compact);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid var(--primary-20);
}
.user-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.user-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.user-name {
font-weight: 600;
color: var(--text-primary);
font-size: 15px;
}
.user-status {
font-size: 12px;
color: var(--success);
display: flex;
align-items: center;
gap: 4px;
}
.user-status::before {
content: '';
display: block;
width: 6px;
height: 6px;
border-radius: 50%;
background-color: var(--success);
}
/* 控制面板(歌单选择与创建) */
.control-panel {
background: var(--surface-subtle);
border-radius: 16px;
padding: 1.25rem;
border: 1px solid var(--overlay-5);
margin-bottom: 1.5rem;
}
.panel-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-label {
font-size: 13px;
font-weight: 500;
color: var(--overlay-50);
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* 创建新歌单的输入组样式优化 */
.input-group {
display: flex;
gap: 0.75rem;
align-items: center;
position: relative; /* 确保定位上下文 */
width: 100%; /* 占满父容器宽度 */
}
.input-group .custom-input {
flex: 1;
width: 0; /* 允许flex缩小 */
min-width: 0; /* 防止内容溢出 */
}
/* 确保按钮不被挤压,且不溢出 */
.input-group .btn-secondary {
flex-shrink: 0;
white-space: nowrap;
margin-left: auto; /* 在必要时推向右侧 */
}
.select-wrapper {
position: relative;
flex: 1;
}
.custom-select,
.custom-input {
width: 100%;
background: var(--panel-bg-subtle);
border: 1px solid var(--overlay-10);
color: var(--text-primary);
padding: 0.75rem 1rem;
border-radius: 10px;
font-size: 14px;
transition: all 0.2s ease;
appearance: none;
}
.custom-select {
padding-right: 2.5rem;
cursor: pointer;
}
.select-arrow {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
pointer-events: none;
color: var(--overlay-40);
}
.custom-select:focus,
.custom-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px var(--primary-10);
background: var(--panel-bg-inset);
}
.btn-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: var(--overlay-5);
border: 1px solid var(--overlay-5);
color: var(--overlay-80);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.btn-icon:hover {
background: var(--overlay-10);
color: var(--text-primary);
}
.btn-icon:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spin {
animation: spin 1s linear infinite;
}
.playlist-actions-row {
display: flex;
justify-content: flex-end;
margin-top: -0.25rem;
}
.btn-text-danger {
background: none;
border: none;
color: var(--error-80);
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
cursor: pointer;
padding: 4px 8px;
border-radius: 6px;
transition: all 0.2s ease;
}
.btn-text-danger:hover {
background: var(--error-10);
color: var(--color-error);
}
.divider {
display: flex;
align-items: center;
margin: 1.25rem 0;
color: var(--overlay-20);
font-size: 12px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: var(--overlay-8);
}
.divider span {
padding: 0 1rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
width: fit-content;
}
.checkbox-wrapper input {
display: none;
}
.checkbox-custom {
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid var(--overlay-30);
position: relative;
transition: all 0.2s ease;
}
.checkbox-wrapper input:checked + .checkbox-custom {
background: var(--primary);
border-color: var(--primary);
}
.checkbox-wrapper input:checked + .checkbox-custom::after {
content: '';
position: absolute;
left: 5px;
top: 1px;
width: 4px;
height: 9px;
border: solid white;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.checkbox-label {
font-size: 13px;
color: var(--overlay-70);
}
/* 歌曲选择面板 */
.songs-selection-panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
margin-top: 0.5rem;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
padding: 0 0.25rem;
}
.highlight-count {
color: var(--primary);
font-weight: 600;
}
.panel-actions {
display: flex;
gap: 1rem;
}
.btn-text {
background: none;
border: none;
color: var(--primary);
font-size: 13px;
cursor: pointer;
padding: 0;
}
.btn-text:hover {
text-decoration: underline;
}
.songs-list {
background: var(--surface-subtle);
border-radius: 12px;
border: 1px solid var(--overlay-5);
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
}
.empty-state {
padding: 2rem;
text-align: center;
color: var(--overlay-40);
font-size: 14px;
background: var(--surface-subtle);
border-radius: 12px;
}
.song-item {
display: flex;
align-items: center;
gap: 0.8rem;
padding: 0.8rem 1rem;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
margin-bottom: 4px;
}
.song-item:last-child {
margin-bottom: 0;
}
.song-item:hover {
background: var(--overlay-5);
}
.song-item.selected {
background: var(--color-accent-alpha-10);
border-color: var(--color-accent-alpha-20);
}
.song-checkbox {
width: 20px;
height: 20px;
border-radius: 6px;
border: 2px solid var(--overlay-30);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
.song-item.selected .song-checkbox {
background: var(--primary);
border-color: var(--primary);
}
.song-details {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.song-name {
font-size: 14px;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
margin-bottom: 0;
}
.song-artist {
font-size: 12px;
color: var(--overlay-50);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
/* 底部按钮栏 */
.playlist-modal-footer {
padding: 1.25rem 1.5rem;
background: var(--panel-bg-overlay);
border-top: 1px solid var(--overlay-8);
display: flex;
justify-content: flex-end;
gap: 1rem;
}
.btn-primary,
.btn-secondary,
.btn-ghost {
padding: 0.6rem 1.25rem;
border-radius: 10px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
border: none;
white-space: nowrap;
flex-shrink: 0;
}
.btn-primary {
background: var(--primary);
color: var(--text-primary);
box-shadow: 0 4px 12px var(--primary-25);
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px var(--primary-35);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: var(--overlay-10);
color: var(--text-primary);
}
.btn-secondary:hover {
background: var(--overlay-15);
}
.btn-ghost {
background: transparent;
color: var(--overlay-60);
}
.btn-ghost:hover {
background: var(--overlay-5);
color: var(--text-primary);
}
/* 动画定义 */
@keyframes modal-slide-in {
from {
opacity: 0;
transform: scale(0.95) translateY(10px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
/* 移动端添加歌单按钮 */
.mobile-add-playlist-btn {
display: none;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid var(--overlay-10);
background: var(--overlay-10);
color: var(--text-primary);
cursor: pointer;
margin-right: 0.5rem;
backdrop-filter: blur(10px);
transition: all 0.2s ease;
}
.mobile-add-playlist-btn:active {
transform: scale(0.95);
background: var(--overlay-20);
}
/* 响应式调整 */
@media (max-width: 768px) {
.mobile-add-playlist-btn {
display: flex;
}
.playlist-modal {
max-width: 100%;
width: 100%;
height: auto;
max-height: 90vh;
border-radius: 16px;
}
.playlist-modal-body {
padding: 1rem;
}
.control-panel {
padding: 1rem;
}
.playlist-modal-footer {
padding: 1rem;
flex-direction: column-reverse;
}
.playlist-modal-footer button {
width: 100%;
}
/* 移动端创建歌单输入组改为垂直排列 */
.input-group.create-playlist-group {
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
}
.input-group.create-playlist-group .custom-input {
width: 100%;
flex: none;
}
.input-group.create-playlist-group .btn-secondary {
width: 100%;
margin-left: 0;
justify-content: center;
}
}
/* 针对不同屏幕尺寸的响应式调整 */
@media (max-width: 1400px) {
.song-card {
width: 300px;
}
}
@media (max-width: 1200px) {
.song-card {
width: 280px;
}
}
@media (max-width: 1024px) {
.song-card {
width: calc(50% - 0.5rem);
}
}
@media (max-width: 768px) {
.song-card {
width: 100%;
}
}
.song-card-main {
padding: 1rem;
box-shadow: 0px 4px 4px var(--mask-25);
position: relative;
height: 90px; /* 减小卡片高度 */
display: flex; /* 使用flex布局 */
align-items: center; /* 垂直居中 */
gap: 15px; /* 元素之间的间隔 */
}
/* 歌曲封面样式 */
.song-cover {
width: 55px;
height: 55px;
aspect-ratio: 1;
flex-shrink: 0;
position: relative;
border-radius: 6px;
overflow: hidden;
box-shadow: 0 2px 8px var(--mask-30);
}
.cover-image {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 文字封面样式 */
.text-cover {
width: 100%;
height: 100%;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent) 100%);
color: var(--text-primary);
font-size: 28px;
font-weight: bold;
font-family: 'MiSans', sans-serif;
}
/* 播放按钮叠加层 */
.play-button-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background: var(--mask-40);
opacity: 0;
transition: opacity 0.2s ease;
cursor: pointer;
}
.song-cover:hover .play-button-overlay {
opacity: 1;
}
/* 播放按钮样式 */
.play-button {
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--color-accent-alpha-80);
border: none;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 12px;
cursor: pointer;
transition: transform 0.2s ease;
}
.play-button:hover {
transform: scale(1.1);
}
.play-icon {
margin-left: 2px;
}
.pause-icon {
font-size: 10px;
}
.song-info {
width: calc(70% - 75px); /* 减去封面宽度和间距 */
}
.song-title {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
letter-spacing: 0.04em;
color: var(--text-primary);
margin-bottom: 0.5rem;
display: flex;
align-items: flex-start;
gap: 0.5rem;
line-height: 1.4;
overflow: hidden;
}
.title-text {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 重播标识 */
.replay-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
background: var(--color-accent-alpha-15);
border: 1px solid var(--color-accent-alpha-30);
border-radius: 4px;
color: var(--color-accent);
font-size: 12px;
font-weight: 500;
flex-shrink: 0;
cursor: help;
transition: all 0.2s ease;
position: relative;
z-index: 1;
}
.replay-badge:hover {
background: var(--color-accent-alpha-25);
border-color: var(--color-accent-alpha-50);
}
/* 点歌券标识 */
.card-code-tag {
display: inline-flex;
align-items: center;
padding: 2px 6px;
background: var(--card-code-bg);
border: 1px solid var(--card-code-border);
border-radius: 4px;
color: var(--card-code-text);
font-size: 0.7rem;
font-weight: 500;
flex-shrink: 0;
white-space: nowrap;
}
.song-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.requester {
font-family: 'MiSans', sans-serif;
font-weight: normal;
font-size: 12px;
color: var(--overlay-40);
text-align: left;
}
/* 重播申请人数样式 */
.replay-requester {
/* 使用和普通投稿人相同的颜色 */
color: var(--overlay-40);
font-weight: normal;
cursor: help;
}
/* 热度样式 */
.action-area {
position: absolute;
top: 50%;
right: 1rem;
transform: translateY(-50%);
display: flex;
flex-direction: row;
align-items: center;
}
.vote-count {
display: flex;
flex-direction: column;
align-items: center;
}
.vote-count .count {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 20px;
color: var(--color-accent);
text-shadow:
0px 20px 30px var(--primary-50),
0px 8px 15px var(--primary-50),
0px 4px 10px var(--schedule-list-title-shadow),
0px 2px 10px var(--schedule-list-title-shadow-sm),
inset 3px 3px 10px var(--overlay-40),
inset -1px -1px 15px var(--overlay-40);
}
.vote-count .label {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 12px;
color: var(--text-primary);
opacity: 0.4;
}
/* ==================== 移动端设计 ==================== */
@media (max-width: 768px) {
.schedule-list {
width: 100% !important;
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
overflow: hidden;
min-height: auto;
}
.schedule-container {
flex-direction: column;
width: 100% !important;
max-width: 100% !important;
padding: 0 !important;
margin: 0 !important;
gap: 12px;
min-height: auto;
}
.date-selector {
width: 100% !important;
max-width: 100% !important;
margin-bottom: 0;
padding: 0 !important;
}
/* 移动端日期导航 - 胶囊式设计 */
.mobile-date-nav {
display: flex !important;
align-items: center;
justify-content: center;
gap: 12px;
width: 100% !important;
padding: 16px 12px !important;
background: transparent !important;
box-sizing: border-box;
}
.nav-capsule {
display: flex;
align-items: center;
background: var(--overlay-5);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 20px;
border: 1px solid var(--overlay-10);
padding: 4px;
flex: 1;
max-width: none;
box-shadow: 0 4px 20px var(--mask-20);
}
.nav-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: var(--overlay-60);
border-radius: 16px;
transition: all 0.2s ease;
}
.nav-btn:active:not(:disabled) {
background: var(--overlay-10);
color: var(--color-accent);
}
.nav-btn:disabled {
opacity: 0.2;
}
.current-date-display {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 8px;
height: 36px;
cursor: pointer;
}
.date-text {
font-size: 15px;
font-weight: 600;
color: var(--text-primary);
white-space: nowrap;
text-shadow:
0 0 10px var(--overlay-20),
0 0 20px var(--color-accent-alpha-15);
}
.dropdown-icon {
opacity: 0.5;
color: var(--text-primary);
}
.mobile-action-btn {
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent) 100%);
border: none;
border-radius: 16px;
box-shadow: 0 4px 15px var(--color-accent-alpha-40);
transition: all 0.2s ease;
flex-shrink: 0;
}
.mobile-action-btn:active {
transform: scale(0.92);
box-shadow: 0 2px 8px var(--color-accent-alpha-40);
}
/* 移除旧样式 */
.date-nav-btn,
.current-date-mobile,
.mobile-add-playlist-btn {
display: none;
}
/* 隐藏桌面端日期列表 */
.date-list {
height: 0;
overflow: hidden;
opacity: 0;
visibility: hidden;
position: absolute;
}
.scroll-indicator-container {
display: none;
}
.mobile-scroll-hint {
display: none;
}
.vertical-divider {
display: none;
}
.schedule-content {
max-width: 100% !important;
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
box-sizing: border-box;
min-height: auto;
}
/* 隐藏桌面端日期标题 */
.schedule-header {
display: none;
}
/* 时段标题 */
.playtime-group {
margin-bottom: 20px;
}
.playtime-header {
padding: 0 4px;
margin-bottom: 12px;
}
.playtime-header h4 {
font-size: 13px;
font-weight: 500;
color: var(--overlay-50);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.playtime-time {
font-size: 12px;
color: var(--overlay-30);
}
/* 歌曲卡片 - 无边框卡片设计 */
.song-cards {
gap: 8px;
display: flex;
flex-direction: column;
}
.song-card {
width: 100%;
background: var(--overlay-12);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 18px;
overflow: hidden;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid var(--overlay-20);
box-shadow: 0 4px 15px var(--mask-20);
}
.song-card.playing {
background: var(--color-accent-alpha-18);
border-color: var(--color-accent-alpha-50);
box-shadow: 0 0 20px var(--color-accent-alpha-30);
}
.song-card.playing .song-title {
color: var(--color-accent);
text-shadow: 0 0 10px var(--color-accent-alpha-40);
}
.song-card:active {
transform: scale(0.98);
background: var(--overlay-15);
border-color: var(--overlay-30);
}
.song-card.played {
opacity: 0.8;
filter: grayscale(0.35);
background: var(--overlay-8);
border-color: var(--overlay-10);
}
.song-card-main {
height: auto;
min-height: 72px;
padding: 14px;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
gap: 14px;
background: transparent;
box-shadow: none;
border-radius: 0;
margin: 0;
}
.action-area {
position: static;
transform: none;
margin-left: auto;
padding-left: 8px;
flex-shrink: 0;
}
/* 歌曲封面 - 更大的圆角 */
.song-cover {
width: 48px;
height: 48px;
aspect-ratio: 1;
border-radius: 10px;
box-shadow: 0 4px 12px var(--mask-30);
}
.text-cover {
font-size: 20px;
}
/* 播放按钮 */
.play-button-overlay {
display: none !important;
}
.song-cover {
cursor: pointer;
}
.song-cover:active {
transform: scale(0.95);
}
.song-info {
flex: 1;
min-width: 0;
padding-right: 4px;
}
.song-title {
font-size: 15px;
font-weight: 600;
margin-bottom: 2px;
line-height: 1.4;
color: var(--text-primary);
letter-spacing: 0.01em;
}
.requester {
font-size: 12px;
color: var(--overlay-50);
font-weight: 400;
}
/* 热度区域 */
.vote-count .count {
font-size: 22px;
font-weight: 800;
color: var(--color-accent);
font-family: 'MiSans-Bold', sans-serif;
line-height: 1;
}
.vote-count .label {
font-size: 11px;
font-weight: 600;
color: var(--overlay-40);
margin-top: 2px;
}
/* 加载和空状态 - 无边框 */
.loading,
.error,
.empty {
padding: 40px 20px;
width: 100%;
background: transparent;
border-radius: 0;
margin: 0;
}
.empty .icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
/* 日期选择弹窗 */
.date-picker-content {
background: var(--panel-bg);
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 100%;
max-height: 70vh;
border: none;
box-shadow: 0 -10px 40px var(--mask-50);
}
.date-picker-header {
padding: 20px;
border-bottom: 1px solid var(--overlay-6);
}
.date-picker-header h3 {
font-size: 17px;
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 10px;
background: var(--overlay-6);
display: flex;
align-items: center;
justify-content: center;
}
.date-picker-list {
padding: 12px;
}
.date-picker-item {
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 6px;
background: var(--overlay-3);
border: none;
font-size: 14px;
}
.date-picker-item:hover {
background: var(--overlay-6);
transform: none;
}
.date-picker-item.active {
background: var(--color-accent-alpha-15);
border-left: none;
color: var(--color-accent);
}
}
/* 小屏幕设备额外优化 */
@media (max-width: 480px) {
.mobile-date-nav {
padding: 10px 0 !important;
}
.date-nav-btn {
width: 32px;
height: 32px;
}
.current-date-mobile {
font-size: 14px;
padding: 6px 12px;
border-radius: 10px;
}
.song-card-main {
min-height: 60px;
padding: 10px;
gap: 10px;
}
.song-cover {
width: 44px;
height: 44px;
aspect-ratio: 1;
border-radius: 8px;
}
.song-title {
font-size: 13px;
}
.requester {
font-size: 11px;
}
.vote-count .count {
font-size: 14px;
}
.play-button {
width: 24px;
height: 24px;
}
}
/* 添加日期切换过渡动画 */
.schedule-fade-enter-active,
.schedule-fade-leave-active {
transition:
opacity 0.5s ease,
transform 0.5s ease;
}
.schedule-fade-enter-from {
opacity: 0;
transform: translateX(30px);
}
.schedule-fade-leave-to {
opacity: 0;
transform: translateX(-30px);
}
/* 波纹效果样式 */
.ripple-effect {
position: absolute;
border-radius: 50%;
background-color: var(--overlay-20);
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;
}
}
/* 左侧日期选择器 - 移除背景和边框 */
.date-selector {
width: 200px;
flex-shrink: 0;
}
.date-list {
max-height: 500px;
overflow-y: auto;
}
/* 移动端滑动提示 */
.mobile-scroll-hint {
display: none;
text-align: center;
font-size: 12px;
color: var(--overlay-50);
margin-bottom: 0.5rem;
}
/* 滚动指示器 */
.scroll-indicator-container {
display: none;
width: 100%;
height: 2px;
background-color: var(--overlay-10);
margin-top: 0.5rem;
border-radius: 1px;
overflow: hidden;
}
.scroll-indicator {
height: 100%;
width: 20%;
background-color: var(--color-accent-alpha-60);
border-radius: 1px;
animation: scroll-hint 1.5s infinite;
}
@keyframes scroll-hint {
0% {
transform: translateX(0);
}
50% {
transform: translateX(400%);
}
100% {
transform: translateX(0);
}
}
/* 移动端日期导航 */
.mobile-date-nav {
display: none;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
background: var(--panel-bg);
border-radius: 10px;
padding: 0.75rem 1rem;
box-shadow: 0px 4px 6px var(--mask-10);
width: 100%;
position: relative;
z-index: 10;
min-width: 100%;
}
.date-nav-btn {
background: var(--primary-light);
border: 1px solid var(--primary-border);
color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.date-nav-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.date-nav-btn:hover:not(:disabled) {
background: var(--color-accent-alpha-20);
}
.current-date-mobile {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: var(--text-primary);
text-align: center;
flex: 1;
white-space: pre-line;
cursor: pointer;
position: relative;
transition: all 0.2s ease;
}
.current-date-mobile:after {
content: '▼';
font-size: 10px;
opacity: 0.7;
margin-left: 5px;
display: inline-block;
vertical-align: middle;
}
.current-date-mobile:hover {
color: var(--color-accent);
}
/* 日期选择器弹窗样式 */
.date-picker-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
z-index: 100;
display: flex;
justify-content: center;
align-items: center;
}
.date-picker-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--mask-50);
backdrop-filter: blur(3px);
}
.date-picker-content {
position: relative;
width: 85%;
max-width: 350px;
max-height: 70vh;
background: var(--panel-bg-overlay);
border-radius: 12px;
box-shadow: 0 10px 25px var(--mask-30);
border: 1px solid var(--overlay-10);
overflow: hidden;
animation: scale-in 0.2s ease;
display: flex;
flex-direction: column;
}
.date-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid var(--overlay-10);
}
.date-picker-header h3 {
margin: 0;
font-size: 16px;
color: white;
}
.close-btn {
background: none;
border: none;
color: var(--overlay-60);
font-size: 22px;
cursor: pointer;
padding: 0 5px;
line-height: 1;
}
.date-picker-list {
padding: 1rem;
overflow-y: auto;
max-height: 60vh;
}
.date-picker-item {
padding: 0.75rem 1rem;
border-radius: 8px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
background: var(--overlay-5);
position: relative;
overflow: hidden;
white-space: pre-line;
}
.date-picker-item:hover {
background: var(--color-accent-alpha-10);
transform: translateY(-2px);
}
.date-picker-item.active {
background: var(--color-accent-alpha-20);
border-left: 3px solid var(--color-accent);
}
/* 过渡动画 */
.date-picker-fade-enter-active,
.date-picker-fade-leave-active {
transition: opacity 0.2s ease;
}
.date-picker-fade-enter-from,
.date-picker-fade-leave-to {
opacity: 0;
}
@keyframes scale-in {
0% {
transform: scale(0.9);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 1;
}
}
.submission-note-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 22px;
margin-left: 6px;
border: 1px solid var(--primary-30);
border-radius: 999px;
background: var(--primary-10);
color: var(--color-accent-light);
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 0 0 transparent;
flex-shrink: 0;
}
.submission-note-trigger:hover {
background: var(--primary-18);
border-color: var(--primary-50);
box-shadow: 0 6px 12px var(--primary-15);
}
.submission-note-modal {
width: 100%;
max-width: 400px;
background: var(--panel-bg-overlay);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--overlay-8);
border-radius: 20px;
padding: 24px;
color: var(--text-secondary);
box-shadow: 0 25px 50px -12px var(--mask-50);
}
.submission-note-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.submission-note-header h4 {
font-size: 18px;
font-weight: 600;
color: var(--text-secondary);
margin: 0;
}
.submission-note-header button {
border: none;
background: var(--overlay-5);
border-radius: 50%;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted-light);
cursor: pointer;
transition: all 0.2s;
}
.submission-note-header button:hover {
background: var(--overlay-10);
color: var(--text-primary-light);
}
.submission-note-meta {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.song-title-tag {
font-size: 13px;
color: var(--text-muted);
background: var(--overlay-6);
padding: 4px 10px;
border-radius: 6px;
}
.visibility-tag {
font-size: 12px;
padding: 3px 8px;
border-radius: 6px;
}
.visibility-public {
background: var(--primary-15);
color: var(--color-accent-light);
border: 1px solid var(--primary-20);
}
.visibility-private {
background: var(--warning-15);
color: var(--color-warning-light);
border: 1px solid var(--warning-20);
}
.submission-note-content-box {
background: var(--surface-card-bg-soft);
border: 1px solid var(--overlay-5);
border-radius: 12px;
padding: 16px;
max-height: 300px;
overflow-y: auto;
}
.submission-note-content {
font-size: 14px;
line-height: 1.7;
color: var(--text-primary-lighter);
white-space: pre-wrap;
word-break: break-word;
margin: 0;
}
</style>