- 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首」提示
3691 lines
86 KiB
Vue
3691 lines
86 KiB
Vue
<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>
|