Files
BYW-VoiceHub/app/components/Songs/ScheduleList.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

3691 lines
86 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-container">
<div class="date-strip">
<button
v-for="(date, index) in availableDates"
:key="date"
:class="['date-pill', { selected: currentDateIndex === index }]"
@click="selectDate(index)"
>
<span>{{ new Date(date + 'T00:00:00').getMonth() + 1 }}月{{ new Date(date + 'T00:00:00').getDate() }}日</span>
<span style="font-size:11px; opacity:0.75; font-weight:500;">周{{ ['日','一','二','三','四','五','六'][new Date(date + 'T00:00:00').getDay()] }}</span>
</button>
<div v-if="availableDates.length === 0" class="empty-dates">暂无排期日期</div>
</div>
<div v-if="isNeteaseLoggedIn" class="playlist-action-row">
<button class="add-playlist-btn-mini" type="button" @click="handleAddToPlaylistClick">
<Icon :size="14" color="#A855F7" name="music" />
<span>添加到歌单</span>
</button>
</div>
<div class="playtime-groups">
<Transition mode="out-in" name="schedule-fade">
<div v-if="loading" key="loading" class="loading-state">加载中...</div>
<div v-else-if="error" key="error" class="error-state">
{{ error }}
</div>
<div v-else-if="!schedules || schedules.length === 0" key="empty-all" class="empty-all-state">
<div class="empty-icon">🎵</div>
<p>暂无排期信息</p>
<p class="empty-hint">点歌后等待管理员安排播出时间</p>
</div>
<div v-else-if="currentDateSchedules.length === 0" key="empty-date" class="empty-all-state">
<div class="empty-icon">📅</div>
<p>当前日期暂无排期</p>
<p class="empty-hint">请选择其他日期查看</p>
</div>
<div v-else :key="currentDate" class="playtime-groups-inner">
<template v-if="schedulesByPlayTime && Object.keys(schedulesByPlayTime).length > 0">
<section
v-for="(schedules, playTimeId) in schedulesByPlayTime"
:key="playTimeId"
class="playtime-group-section"
>
<div v-if="shouldShowPlayTimeHeader(playTimeId)" class="playtime-group-header">
<span class="playtime-name">
<template v-if="playTimeId === 'null'">未指定时段</template>
<template v-else-if="getPlayTimeById(playTimeId)">
{{ getPlayTimeById(playTimeId).name }}
<span
v-if="
getPlayTimeById(playTimeId).startTime || getPlayTimeById(playTimeId).endTime
"
class="playtime-time-range"
>
({{ formatPlayTimeRange(getPlayTimeById(playTimeId)) }})
</span>
</template>
<template v-else>{{ playTimeId }}</template>
</span>
<span class="playtime-count-badge">{{ schedules.length }} 首</span>
</div>
<div class="playtime-group-songs">
<div v-if="schedules.length === 0" class="empty-state">该时段暂无排期 📻</div>
<div
v-for="schedule in schedules"
:key="schedule.id"
:class="[
'schedule-song-card',
{
'card-played': schedule.song.played && schedule.song.replayRequestCount === 0,
'card-playing': isCurrentPlaying(schedule.song.id)
}
]"
>
<div class="song-sequence-num">{{ schedule.sequence || '–' }}</div>
<div class="song-main-info">
<div
class="song-title"
:title="schedule.song.title + ' - ' + schedule.song.artist"
>
{{ schedule.song.title }}
<span
v-if="schedule.song.replayRequestCount > 0"
class="replay-badge"
title="重播歌曲"
>
<Icon name="repeat" :size="14" />
</span>
</div>
<div class="song-meta">
<span class="song-artist">{{ schedule.song.artist }}</span>
<span class="meta-dot">·</span>
<template v-if="schedule.song.replayRequestCount > 0">
<span
:title="
'重播申请人:' +
(schedule.song.replayRequesters || [])
.map((r) => r.displayName || r.name)
.join('、')
"
class="requester-tag"
>
申请人:{{
(schedule.song.replayRequesters || [])[0]
? (schedule.song.replayRequesters[0].displayName ||
schedule.song.replayRequesters[0].name) +
(schedule.song.replayRequestCount > 1 ? '...' : '')
: '未知'
}}
</span>
</template>
<template v-else>
<span
:title="
(schedule.song.collaborators && schedule.song.collaborators.length > 0
? '主投稿人: '
: '投稿人: ') +
schedule.song.requester +
(schedule.song.collaborators && schedule.song.collaborators.length
? '\n联合投稿: ' +
schedule.song.collaborators
.map((c) => c.displayName || c.name)
.join(', ')
: '')
"
class="requester-tag"
>
投稿人:{{ 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>
</template>
</div>
</div>
<div
v-if="
(schedule.song.musicPlatform && schedule.song.musicId) ||
schedule.song.playUrl
"
class="song-audio-btn"
@click.stop="togglePlaySong(schedule.song)"
>
<button
class="audio-btn-inner"
:title="isCurrentPlaying(schedule.song.id) ? '暂停' : '试听'"
type="button"
>
<Icon
v-if="isCurrentPlaying(schedule.song.id)"
:size="14"
color="#A855F7"
name="pause"
/>
<Icon v-else :size="14" color="#A855F7" name="play" />
</button>
</div>
<div class="status-badges">
<span
:class="[
'status-badge',
schedule.status === 'APPROVED'
? 'status-approved'
: schedule.status === 'PENDING'
? 'status-pending'
: 'status-played'
]"
>
{{
schedule.status === 'APPROVED'
? '排期中'
: schedule.status === 'PENDING'
? '审核中'
: '已播出'
}}
</span>
</div>
</div>
</div>
</section>
</template>
</div>
</Transition>
</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-black/60 backdrop-blur-sm" />
<div
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 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-blue-600/10 flex items-center justify-center text-blue-500"
>
<Icon name="music" :size="24" />
</div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight">添加到歌单</h3>
</div>
<button
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 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-zinc-800/50 flex items-center justify-center mb-6"
>
<Icon name="music" :size="40" class="text-zinc-500 opacity-20" />
</div>
<p class="text-zinc-400 font-medium mb-8">需要登录网易云音乐账号才能管理歌单</p>
</div>
<div v-else class="space-y-8">
<div
v-if="neteaseUser"
class="flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-2xl"
>
<div
class="w-12 h-12 rounded-xl overflow-hidden bg-zinc-800 mr-4 ring-2 ring-zinc-700/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-zinc-500" />
</div>
<div class="flex-1 min-w-0">
<span
class="block text-[10px] font-black text-zinc-500 uppercase tracking-widest mb-0.5"
>当前账号</span
>
<span class="block font-bold text-zinc-100 truncate">
{{ neteaseUser.nickname || neteaseUser.userName || '网易云用户' }}
</span>
</div>
</div>
<div class="space-y-6">
<div class="space-y-3">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>选择目标歌单</label
>
<div class="flex gap-3">
<CustomSelect
v-model="selectedPlaylistId"
:options="formattedPlaylists"
label-key="displayName"
value-key="id"
placeholder="请选择歌单"
class="flex-1"
/>
<button
:disabled="playlistsLoading"
class="w-10 h-[38px] flex items-center justify-center rounded-lg bg-zinc-950 border border-zinc-800 text-zinc-500 hover:text-zinc-200 hover:border-zinc-700 transition-all disabled:opacity-50"
title="刷新歌单列表"
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-red-400/60 hover:text-red-400 flex items-center gap-1.5 transition-colors uppercase tracking-wider"
type="button"
@click="handleDeletePlaylist"
>
<Icon name="trash" :size="14" />
删除当前歌单
</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-zinc-800/30" />
</div>
<span
class="relative px-4 bg-zinc-900 text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]"
>或</span
>
</div>
<div class="space-y-4">
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
>创建新歌单</label
>
<div class="flex gap-3">
<input
v-model="newPlaylistName"
class="flex-1 px-5 py-3.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-100 text-sm placeholder-zinc-600 focus:outline-none focus:border-blue-500/30 transition-all"
placeholder="输入新歌单名称"
type="text"
>
<button
:disabled="!newPlaylistName.trim() || playlistActionLoading"
class="px-8 py-3.5 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
type="button"
@click="handleCreatePlaylist"
>
{{ playlistActionLoading ? '...' : '新建' }}
</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-zinc-800 rounded-full border border-zinc-700 peer-checked:bg-blue-600 peer-checked:border-blue-500 transition-all"
/>
<div
class="absolute left-1 top-1 w-3 h-3 bg-zinc-500 rounded-full transition-all peer-checked:left-5 peer-checked:bg-white"
/>
</div>
<span
class="text-[10px] font-black text-zinc-500 uppercase tracking-widest group-hover:text-zinc-300 transition-colors"
>设为隐私歌单</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-zinc-500 uppercase tracking-widest">
选择歌曲
<span
class="ml-2 px-2 py-0.5 rounded-md bg-blue-600/10 text-blue-500 text-[9px]"
>{{ selectedSongIds.length }} / {{ neteaseSongs.length }}</span
>
</label>
<div class="flex gap-4">
<button
class="text-[10px] font-black text-zinc-400 hover:text-blue-500 uppercase tracking-wider transition-colors"
type="button"
@click="selectAllNeteaseSongs"
>
全选
</button>
<button
class="text-[10px] font-black text-zinc-400 hover:text-red-400 uppercase tracking-wider transition-colors"
type="button"
@click="clearSelectedSongs"
>
清空
</button>
</div>
</div>
<div
v-if="neteaseSongs.length === 0"
class="flex flex-col items-center justify-center py-12 bg-zinc-950/30 border border-dashed border-zinc-800 rounded-3xl text-zinc-600"
>
<Icon name="music" :size="32" class="mb-3 opacity-20" />
<p class="text-[10px] font-black uppercase tracking-widest">
当前日期没有来自网易云的歌曲
</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-blue-600/10 border-blue-500/30 shadow-lg'
: 'bg-zinc-950 border-transparent hover:border-zinc-800'
]"
@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-blue-600 border-blue-600 text-white'
: 'border-zinc-800 group-hover:border-zinc-700'
]"
>
<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-zinc-100">
{{ song.title }}
</div>
<div
class="text-[10px] font-black uppercase tracking-widest truncate mt-0.5 text-zinc-500"
>
{{ 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-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
type="button"
@click="closePlaylistModal"
>
取消
</button>
<button
:disabled="
!selectedPlaylistId || selectedSongIds.length === 0 || playlistActionLoading
"
class="flex-[2] px-6 py-4 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 shadow-lg shadow-blue-900/20 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 ? '正在添加...' : '确认添加' }}</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>
</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 ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { convertToHttps } from '~/utils/url'
import { getMusicUrl as resolveMusicUrl } from '~/utils/musicUrl'
const props = defineProps({
schedules: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
}
})
const audioPlayer = useAudioPlayer()
const { checkNeteaseLoginStatus: updateGlobalNeteaseStatus } = useAudioQuality()
const { playTimeEnabled } = useSongs()
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} (${pl.trackCount}首)`
}))
})
const selectedPlaylistId = ref('')
const playlistActionLoading = ref(false)
const selectedSongIds = ref([])
const newPlaylistName = ref('')
const newPlaylistPrivacy = ref(false)
const confirmDialog = ref({
show: false,
title: '',
message: '',
type: 'warning',
onConfirm: null
})
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 {
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()
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'
})
}
const findAndSelectTodayOrClosestDate = async () => {
if (availableDates.value.length === 0) return
const today = new Date()
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
let selectedIndex = 0
if (!isMobile.value) {
const todayTime = today.getTime()
let closestFutureIndex = -1
let minFutureDiff = Number.MAX_SAFE_INTEGER
availableDates.value.forEach((dateStr, index) => {
const dateParts = dateStr.split('-')
const date = new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2])
)
const diff = date.getTime() - todayTime
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 dateParts = dateStr.split('-')
const date = new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2])
)
const diff = todayTime - date.getTime()
if (diff > 0 && diff < minPastDiff) {
minPastDiff = diff
closestPastIndex = index
}
})
if (closestPastIndex >= 0) {
selectedIndex = closestPastIndex
}
}
} else {
const todayIndex = availableDates.value.findIndex((date) => date === todayStr)
if (todayIndex >= 0) {
selectedIndex = todayIndex
} else {
const todayTime = today.getTime()
let closestDate = -1
let minDiff = Number.MAX_SAFE_INTEGER
availableDates.value.forEach((dateStr, index) => {
const dateParts = dateStr.split('-')
const date = new Date(
parseInt(dateParts[0]),
parseInt(dateParts[1]) - 1,
parseInt(dateParts[2])
)
const diff = Math.abs(date.getTime() - todayTime)
if (diff < minDiff) {
minDiff = diff
closestDate = index
}
})
if (closestDate >= 0) {
selectedIndex = closestDate
}
}
}
currentDateIndex.value = selectedIndex
isInitialized.value = true
lastSelectedDate.value = availableDates.value[selectedIndex]
await scrollToDateItem(selectedIndex)
}
const currentDateFormatted = computed(() => {
if (!currentDate.value) return '无日期'
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()
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 parts = dateStr.split('-')
if (parts.length !== 3) {
throw new Error('无效的日期格式')
}
const year = parseInt(parts[0])
const month = parseInt(parts[1])
const day = parseInt(parts[2])
const date = new Date(year, month - 1, day)
if (isNaN(date.getTime())) {
throw new Error('无效的日期')
}
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const weekday = weekdays[date.getDay()]
if (isMobile) {
return `${month}月${day}日 ${weekday}`
}
return `${year}年${month}月${day}日\n<span class="weekday">${weekday}</span>`
} catch (e) {
return dateStr || '未知日期'
}
}
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 '音'
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))
updateGlobalNeteaseStatus()
}
if (showPlaylistModal.value) {
reloadPlaylists()
}
}
const handleAddToPlaylistClick = () => {
if (!isNeteaseLoggedIn.value) {
return
}
if (!neteaseSongs.value.length) {
if (window.$showNotification) {
window.$showNotification('当前日期排期中没有来自网易云音乐的歌曲', 'warning')
}
return
}
selectedSongIds.value = neteaseSongs.value.map((song) => song.id)
showPlaylistModal.value = true
reloadPlaylists()
}
const closePlaylistModal = () => {
showPlaylistModal.value = false
}
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 ? `获取歌单列表失败:${message}` : '获取歌单列表失败'
window.$showNotification(text, 'error')
}
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification('获取歌单列表失败', '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('歌单创建成功', 'success')
}
newPlaylistName.value = ''
await reloadPlaylists()
if (createdId) {
selectedPlaylistId.value = createdId
}
} else {
if (window.$showNotification) {
const text = message ? `歌单创建失败:${message}` : '歌单创建失败'
window.$showNotification(text, 'error')
}
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification('歌单创建失败', 'error')
}
} finally {
playlistActionLoading.value = false
}
}
const handleDeletePlaylist = async () => {
if (!selectedPlaylistId.value) return
if (!isNeteaseLoggedIn.value || !neteaseCookie.value) return
confirmDialog.value = {
show: true,
title: '删除歌单',
message: '确定要删除当前歌单吗?此操作无法撤销。',
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('歌单删除成功', '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 ? `歌单删除失败:${message}` : '歌单删除失败'
window.$showNotification(text, 'error')
}
closeConfirmDialog()
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification('歌单删除失败', 'error')
}
closeConfirmDialog()
} finally {
playlistActionLoading.value = false
}
}
}
}
const closeConfirmDialog = () => {
confirmDialog.value.show = false
setTimeout(() => {
confirmDialog.value.onConfirm = null
}, 300)
}
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('请先选择要添加的歌曲', 'warning')
}
return
}
playlistActionLoading.value = true
try {
const { code, message } = await addSongsToPlaylist(
selectedPlaylistId.value,
tracks,
neteaseCookie.value
)
if (code === 200) {
if (window.$showNotification) {
window.$showNotification(`成功添加 ${tracks.length} 首歌曲到歌单`, 'success')
}
showPlaylistModal.value = false
} else {
if (window.$showNotification) {
const text = message ? `添加到歌单失败:${message}` : '添加到歌单失败'
window.$showNotification(text, 'error')
}
}
} catch (error) {
if (window.$showNotification) {
window.$showNotification('添加到歌单失败', '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) {
const currentGlobalSong = audioPlayer.getCurrentSong().value
if (
currentGlobalSong &&
(currentGlobalSong.musicUrl)
) {
audioPlayer.playSong(currentGlobalSong)
} else {
if ((song.musicPlatform && song.musicId) || song.playUrl) {
try {
const url = await getMusicUrl(song)
if (url) {
const playableSong = {
...song,
musicUrl: url || null
}
audioPlayer.playSong(playableSong)
} else {
if (window.$showNotification) {
window.$showNotification('无法获取音乐播放链接,请稍后再试', 'error')
}
}
} catch (error) {
console.error('获取音乐URL失败:', error)
if (window.$showNotification) {
window.$showNotification('获取音乐播放链接失败', 'error')
}
}
}
}
return
}
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) {
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('无法获取音乐播放链接,请稍后再试', 'error')
}
audioPlayer.playSong({ ...song, musicUrl: null }, [], -1)
}
} catch (error) {
console.error('获取音乐URL失败:', error)
if (window.$showNotification) {
window.$showNotification('获取音乐播放链接失败', '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
}
const getMusicUrl = async (song) => {
const { musicPlatform: platform, musicId, playUrl, sourceInfo } = song
if (playUrl && playUrl.trim()) {
console.log(`[ScheduleList] 使用自定义播放链接: ${playUrl}`)
return playUrl.trim()
}
if (!platform || !musicId) {
throw new Error('歌曲缺少音乐平台或音乐ID信息,无法获取播放链接')
}
const isPodcast =
platform === 'netease-podcast' ||
sourceInfo?.type === 'voice' ||
(sourceInfo?.source === 'netease-backup' && sourceInfo?.type === 'voice')
const options = isPodcast ? { unblock: false } : {}
return resolveMusicUrl(platform, musicId, undefined, options)
}
const isCurrentPlaying = (songId) => {
return audioPlayer.isCurrentPlaying(songId)
}
const formatPlayTime = (schedule) => {
try {
if (schedule.song && schedule.song.played) {
return '已播放'
} else {
return '已排期'
}
} catch (e) {
return '时间未定'
}
}
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) {
const playTimeId =
schedule.playTimeId !== null && schedule.playTimeId !== undefined
? String(schedule.playTimeId)
: 'null'
if (!grouped[playTimeId]) {
grouped[playTimeId] = []
}
grouped[playTimeId].push(schedule)
}
return grouped
})
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 `${playTime.startTime} 开始`
} else if (playTime.endTime) {
return `${playTime.endTime} 结束`
}
return '不限时间'
}
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)
})
}
}
</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;
}
.semester-selector {
display: flex;
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid var(--border-secondary);
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.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 rgba(0, 0, 0, 0.3);
}
.semester-select {
background: var(--bg-tertiary);
border: 1px solid var(--border-tertiary);
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 rgba(0, 0, 0, 0.1);
}
.semester-select:hover {
background: linear-gradient(135deg, rgba(11, 90, 254, 0.2) 0%, rgba(255, 255, 255, 0.15) 100%);
border-color: rgba(11, 90, 254, 0.6);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(11, 90, 254, 0.2);
}
.semester-select:focus {
outline: none;
border-color: #0b5afe;
box-shadow:
0 0 0 3px rgba(11, 90, 254, 0.3),
0 4px 12px rgba(11, 90, 254, 0.2);
transform: translateY(-1px);
}
.semester-select option {
background: var(--bg-secondary, #1a1d24);
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;
}
.date-selector {
width: 200px;
flex-shrink: 0;
box-sizing: border-box;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.date-list {
flex: 1;
overflow-y: auto;
width: 100%;
min-height: 0;
}
.date-item {
padding: 0.8rem 1rem;
font-family: 'MiSans', sans-serif;
font-weight: 400;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
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(--bg-tertiary, #21242d);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
.date-item.active {
background: var(--bg-tertiary, #21242d);
color: var(--text-primary);
font-weight: 600;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(11, 90, 254, 0.2);
border-left: 3px solid #0b5afe;
}
.empty-dates {
padding: 2rem 1rem;
text-align: center;
color: rgba(255, 255, 255, 0.6);
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,
rgba(217, 217, 217, 0) 0%,
rgba(217, 217, 217, 0.5) 50%,
rgba(217, 217, 217, 0) 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;
}
.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(--bg-tertiary, #21242d);
margin: 1rem 0;
color: rgba(255, 255, 255, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.loading::before {
content: '';
display: block;
width: 40px;
height: 40px;
margin-bottom: 1rem;
border-radius: 50%;
border: 3px solid rgba(11, 90, 254, 0.2);
border-top-color: #0b5afe;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.error,
.empty {
padding: 2rem;
text-align: center;
border-radius: 10px;
background: var(--bg-tertiary, #21242d);
margin: 1rem 0;
color: rgba(255, 255, 255, 0.6);
}
.error {
color: #ef4444;
}
.empty .icon {
font-size: 3rem;
opacity: 0.5;
}
.schedule-items {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.playtime-group {
margin-bottom: 2rem;
}
.playtime-header h4 {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 1rem 0;
}
.playtime-time {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
margin-left: 0.5rem;
}
.song-cards {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.song-card {
width: 320px;
flex-shrink: 0;
background: var(--bg-tertiary, #21242d);
border-radius: 10px;
overflow: hidden;
position: relative;
}
.add-playlist-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
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: rgba(255, 255, 255, 0.2);
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.2);
}
.add-playlist-btn:active {
transform: translateY(0);
}
.add-playlist-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
.playlist-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.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(--bg-secondary, #1e1e24);
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
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 rgba(255, 255, 255, 0.08);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(30, 30, 36, 0.95);
}
.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: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.6);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
}
.playlist-modal-close:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
transform: rotate(90deg);
}
.playlist-modal-body {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background: var(--bg-secondary, #1e1e24);
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background-color: rgba(255, 255, 255, 0.2);
}
.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, rgba(102, 126, 234, 0.1) 0%, rgba(102, 126, 234, 0.05) 100%);
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
color: var(--primary);
}
.login-hint {
color: rgba(255, 255, 255, 0.7);
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: rgba(255, 255, 255, 0.03);
border-radius: 12px;
margin-bottom: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
background: var(--bg-tertiary, #2a2a32);
display: flex;
align-items: center;
justify-content: center;
border: 2px solid rgba(102, 126, 234, 0.2);
}
.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: rgba(255, 255, 255, 0.02);
border-radius: 16px;
padding: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.05);
margin-bottom: 1.5rem;
}
.panel-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.section-label {
font-size: 13px;
font-weight: 500;
color: rgba(255, 255, 255, 0.5);
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;
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(--bg-secondary, #141418);
border: 1px solid rgba(255, 255, 255, 0.1);
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: rgba(255, 255, 255, 0.4);
}
.custom-select:focus,
.custom-input:focus {
outline: none;
border-color: var(--input-border-focus, var(--primary));
box-shadow: var(--input-shadow-focus, 0 0 0 3px rgba(102, 126, 234, 0.1));
background: var(--input-bg, #1a1a20);
}
.btn-icon {
width: 42px;
height: 42px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.8);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
flex-shrink: 0;
}
.btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
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: rgba(239, 68, 68, 0.8);
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: rgba(239, 68, 68, 0.1);
color: #ef4444;
}
.divider {
display: flex;
align-items: center;
margin: 1.25rem 0;
color: rgba(255, 255, 255, 0.2);
font-size: 12px;
}
.divider::before,
.divider::after {
content: '';
flex: 1;
height: 1px;
background: rgba(255, 255, 255, 0.08);
}
.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 rgba(255, 255, 255, 0.3);
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: rgba(255, 255, 255, 0.7);
}
.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: rgba(255, 255, 255, 0.03);
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.05);
max-height: 300px;
overflow-y: auto;
padding: 0.5rem;
}
.empty-state {
padding: 2rem;
text-align: center;
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
background: rgba(255, 255, 255, 0.02);
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: rgba(255, 255, 255, 0.05);
}
.song-item.selected {
background: rgba(11, 90, 254, 0.1);
border-color: rgba(11, 90, 254, 0.2);
}
.song-checkbox {
width: 20px;
height: 20px;
border-radius: 6px;
border: 2px solid rgba(255, 255, 255, 0.3);
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: rgba(255, 255, 255, 0.5);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.4;
}
.playlist-modal-footer {
padding: 1.25rem 1.5rem;
background: rgba(30, 30, 36, 0.95);
border-top: 1px solid rgba(255, 255, 255, 0.08);
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 rgba(102, 126, 234, 0.25);
}
.btn-primary:hover {
background: var(--primary-hover);
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.35);
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn-ghost {
background: transparent;
color: rgba(255, 255, 255, 0.6);
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.05);
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 rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
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: rgba(255, 255, 255, 0.2);
}
@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 rgba(0, 0, 0, 0.25);
position: relative;
height: 90px;
display: 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 rgba(0, 0, 0, 0.3);
}
.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(--bg-secondary, #0043f8) 0%, #0075f8 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: rgba(0, 0, 0, 0.4);
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: rgba(11, 90, 254, 0.8);
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: rgba(11, 90, 254, 0.15);
border: 1px solid rgba(11, 90, 254, 0.3);
border-radius: 4px;
color: #0b5afe;
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: rgba(11, 90, 254, 0.25);
border-color: rgba(11, 90, 254, 0.5);
}
.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: rgba(255, 255, 255, 0.4);
text-align: left;
}
.replay-requester {
color: rgba(255, 255, 255, 0.4);
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: #0b5afe;
text-shadow:
0px 20px 30px rgba(0, 114, 248, 0.5),
0px 8px 15px rgba(0, 114, 248, 0.5),
0px 4px 10px rgba(0, 179, 248, 0.3),
0px 2px 10px rgba(0, 179, 248, 0.2),
inset 3px 3px 10px rgba(255, 255, 255, 0.4),
inset -1px -1px 15px rgba(255, 255, 255, 0.4);
}
.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: rgba(255, 255, 255, 0.05);
backdrop-filter: blur(15px);
-webkit-backdrop-filter: blur(15px);
border-radius: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 4px;
flex: 1;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.nav-btn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.6);
border-radius: 16px;
transition: all 0.2s ease;
}
.nav-btn:active:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
color: #0b5afe;
}
.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 rgba(255, 255, 255, 0.2),
0 0 20px rgba(11, 90, 254, 0.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(--bg-secondary, #0b5afe) 0%, #0043f8 100%);
border: none;
border-radius: 16px;
box-shadow: 0 4px 15px rgba(11, 90, 254, 0.4);
transition: all 0.2s ease;
flex-shrink: 0;
}
.mobile-action-btn:active {
transform: scale(0.92);
box-shadow: 0 2px 8px rgba(11, 90, 254, 0.4);
}
.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: rgba(255, 255, 255, 0.5);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.playtime-time {
font-size: 12px;
color: rgba(255, 255, 255, 0.3);
}
.song-cards {
gap: 8px;
display: flex;
flex-direction: column;
}
.song-card {
width: 100%;
background: rgba(255, 255, 255, 0.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 rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.song-card.playing {
background: rgba(11, 90, 254, 0.18);
border-color: rgba(11, 90, 254, 0.5);
box-shadow: 0 0 20px rgba(11, 90, 254, 0.3);
}
.song-card.playing .song-title {
color: #0b5afe;
text-shadow: 0 0 10px rgba(11, 90, 254, 0.4);
}
.song-card:active {
transform: scale(0.98);
background: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.song-card.played {
opacity: 0.8;
filter: grayscale(0.35);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
}
.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 rgba(0, 0, 0, 0.3);
}
.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: rgba(255, 255, 255, 0.5);
font-weight: 400;
}
.vote-count .count {
font-size: 22px;
font-weight: 800;
color: #0b5afe;
font-family: 'MiSans-Bold', sans-serif;
line-height: 1;
}
.vote-count .label {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
}
.loading,
.error,
.empty {
padding: 40px 20px;
width: 100%;
background: transparent;
border-radius: 0;
margin: 0;
}
.loading::before {
width: 32px;
height: 32px;
border-width: 2px;
}
.empty .icon {
font-size: 2.5rem;
margin-bottom: 12px;
}
.date-picker-content {
background: var(--surface-primary, var(--bg-primary, #1a1a1f));
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 100%;
max-height: 70vh;
border: none;
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
}
.date-picker-header {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.date-picker-header h3 {
font-size: 17px;
font-weight: 600;
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
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: rgba(255, 255, 255, 0.03);
border: none;
font-size: 14px;
}
.date-picker-item:hover {
background: rgba(255, 255, 255, 0.06);
transform: none;
}
.date-picker-item.active {
background: rgba(11, 90, 254, 0.15);
border-left: none;
color: #0b5afe;
}
}
@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: rgba(255, 255, 255, 0.2);
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: rgba(255, 255, 255, 0.5);
margin-bottom: 0.5rem;
}
.scroll-indicator-container {
display: none;
width: 100%;
height: 2px;
background-color: rgba(255, 255, 255, 0.1);
margin-top: 0.5rem;
border-radius: 1px;
overflow: hidden;
}
.scroll-indicator {
height: 100%;
width: 20%;
background-color: rgba(11, 90, 254, 0.6);
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(--bg-tertiary, #21242d);
border-radius: 10px;
padding: 0.75rem 1rem;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
width: 100%;
position: relative;
z-index: 10;
min-width: 100%;
}
.date-nav-btn {
background: rgba(11, 90, 254, 0.1);
border: 1px solid var(--border-secondary);
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: rgba(11, 90, 254, 0.2);
}
.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: #0b5afe;
}
.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: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(3px);
}
.date-picker-content {
position: relative;
width: 85%;
max-width: 350px;
max-height: 70vh;
background: var(--bg-secondary, #1a1d24);
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
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 rgba(255, 255, 255, 0.1);
}
.date-picker-header h3 {
margin: 0;
font-size: 16px;
color: white;
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
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: rgba(255, 255, 255, 0.05);
position: relative;
overflow: hidden;
white-space: pre-line;
}
.date-picker-item:hover {
background: rgba(11, 90, 254, 0.1);
transform: translateY(-2px);
}
.date-picker-item.active {
background: rgba(11, 90, 254, 0.2);
border-left: 3px solid #0b5afe;
}
.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;
}
}
.schedule-list-container {
width: 100%;
box-sizing: border-box;
position: relative;
}
.date-strip {
white-space: nowrap;
overflow-x: auto;
padding: 8px 0 16px;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.date-strip::-webkit-scrollbar {
height: 4px;
}
.date-strip::-webkit-scrollbar-thumb {
background: rgba(168, 85, 247, 0.3);
border-radius: 2px;
}
.date-pill {
height: 36px;
padding: 0 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
margin-right: 8px;
display: inline-flex;
align-items: center;
gap: 6px;
background: #ffffff;
border: 1px solid #e5e7eb;
color: #374151;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
flex-shrink: 0;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, sans-serif;
}
.date-pill:hover {
border-color: #d1d5db;
}
.date-pill.selected {
background: linear-gradient(90deg, #a855f7 0%, #ec4899 50%, #3b82f6 100%);
color: #fff;
box-shadow: 0 4px 12px rgba(168, 85, 247, 0.25);
border: none;
height: 38px;
padding: 0 17px;
}
.playlist-action-row {
display: flex;
justify-content: flex-end;
margin-bottom: 12px;
}
.add-playlist-btn-mini {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
border: 1px solid rgba(168, 85, 247, 0.25);
background: rgba(168, 85, 247, 0.08);
color: #a855f7;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.add-playlist-btn-mini:hover {
background: rgba(168, 85, 247, 0.15);
border-color: rgba(168, 85, 247, 0.4);
}
.playtime-groups {
width: 100%;
}
.playtime-groups-inner {
display: flex;
flex-direction: column;
gap: 4px;
}
.playtime-group-section {
margin-bottom: 8px;
}
.playtime-group-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 700;
margin: 16px 0 12px;
padding: 0 2px;
}
.playtime-name {
font-size: 15px;
color: #111827;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, sans-serif;
}
.playtime-time-range {
font-size: 12px;
color: #6b7280;
font-weight: 500;
margin-left: 4px;
}
.playtime-count-badge {
font-size: 11px;
font-weight: 600;
padding: 3px 10px;
border-radius: 999px;
background: linear-gradient(90deg, rgba(168, 85, 247, 0.12), rgba(236, 72, 153, 0.12), rgba(59, 130, 246, 0.12));
color: #7c3aed;
}
.playtime-group-songs .empty-state {
padding: 24px;
background: #f9fafb;
border-radius: 8px;
color: #6b7280;
text-align: center;
font-size: 14px;
font-weight: 500;
}
.schedule-song-card {
display: flex;
align-items: center;
gap: 16px;
padding: 14px 16px;
background: #ffffff;
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 8px;
margin-bottom: 10px;
transition: all 0.15s ease;
position: relative;
}
.schedule-song-card:hover {
border-color: #d1d5db;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
.schedule-song-card.card-playing {
border-color: rgba(168, 85, 247, 0.5);
box-shadow: 0 0 0 3px rgba(168, 85, 247, 0.08);
}
.schedule-song-card.card-played {
opacity: 0.75;
}
.song-sequence-num {
font-size: 28px;
font-weight: 900;
background: linear-gradient(90deg, #a855f7, #ec4899, #3b82f6);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
min-width: 48px;
text-align: center;
letter-spacing: -0.02em;
font-family: 'MiSans-Bold', 'MiSans', -apple-system, sans-serif;
line-height: 1;
flex-shrink: 0;
}
.song-main-info {
flex: 1;
min-width: 0;
}
.song-main-info .song-title {
font-size: 16px;
font-weight: 700;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: flex;
align-items: center;
gap: 6px;
margin: 0;
line-height: 1.3;
letter-spacing: 0.01em;
}
.song-main-info .song-meta {
font-size: 12px;
color: #6b7280;
margin-top: 2px;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0;
}
.song-artist {
font-weight: 500;
}
.meta-dot {
margin: 0 6px;
color: #9ca3af;
}
.requester-tag {
color: #6b7280;
}
.song-main-info .replay-badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px 6px;
background: rgba(168, 85, 247, 0.12);
border: 1px solid rgba(168, 85, 247, 0.25);
border-radius: 4px;
color: #a855f7;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
cursor: help;
margin-left: 2px;
}
.song-audio-btn {
flex-shrink: 0;
}
.audio-btn-inner {
width: 32px;
height: 32px;
border-radius: 8px;
background: rgba(168, 85, 247, 0.1);
border: 1px solid rgba(168, 85, 247, 0.18);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
}
.audio-btn-inner:hover {
background: rgba(168, 85, 247, 0.18);
border-color: rgba(168, 85, 247, 0.3);
}
.status-badges {
flex-shrink: 0;
display: flex;
align-items: center;
}
.status-badge {
padding: 4px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 700;
line-height: 1.4;
white-space: nowrap;
border-width: 1px;
border-style: solid;
}
.status-approved {
background: #ecfdf5;
color: #047857;
border-color: #a7f3d0;
}
.status-pending {
background: #fffbeb;
color: #b45309;
border-color: #fde68a;
}
.status-played {
background: #f3f4f6;
color: #6b7280;
border-color: #e5e7eb;
}
.loading-state {
padding: 48px 24px;
text-align: center;
color: #6b7280;
font-size: 14px;
font-weight: 500;
}
.error-state {
padding: 24px;
text-align: center;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 14px;
font-weight: 500;
}
.empty-all-state {
padding: 48px 24px;
text-align: center;
border-radius: 12px;
background: #f9fafb;
color: #6b7280;
font-size: 14px;
}
.empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: 0.5;
}
.empty-hint {
font-size: 13px;
color: #9ca3af;
margin-top: 6px;
}
@media (max-width: 768px) {
.schedule-list-container {
padding: 0 4px;
}
.date-pill {
height: 34px;
padding: 0 14px;
font-size: 12px;
}
.date-pill.selected {
height: 36px;
}
.playtime-group-header {
margin: 12px 0 8px;
}
.playtime-name {
font-size: 14px;
}
.schedule-song-card {
gap: 10px;
padding: 12px;
flex-wrap: wrap;
}
.song-sequence-num {
font-size: 24px;
min-width: 36px;
}
.song-main-info {
min-width: calc(100% - 140px);
}
.song-main-info .song-title {
font-size: 15px;
}
.song-main-info .song-meta {
font-size: 11px;
}
.audio-btn-inner {
width: 30px;
height: 30px;
}
.status-badge {
padding: 3px 10px;
font-size: 11px;
}
}
</style>