submit
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="app-root">
|
||||
<header class="app-header">
|
||||
<span>Web 点歌台</span>
|
||||
<div class="header-right">
|
||||
<span class="user-id" title="当前用户ID" v-if="userId">
|
||||
ID: {{ userId }}
|
||||
</span>
|
||||
<span class="online-count" title="当前在线人数">
|
||||
👥 {{ onlineCount }}
|
||||
</span>
|
||||
<button class="fullscreen-btn" @click="toggleFullscreen" :title="isFullscreen ? '退出全屏' : '全屏'">
|
||||
{{ isFullscreen ? '🗗' : '🗖' }}
|
||||
</button>
|
||||
<button class="refresh-btn" @click="handleRefresh" title="刷新页面">
|
||||
🔄
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<section class="left-pane">
|
||||
<LeftPanel
|
||||
:search-results="searchResults"
|
||||
:queue-list="queueList"
|
||||
:loading="loading"
|
||||
:keyword="keyword"
|
||||
@update:keyword="onUpdateKeyword"
|
||||
@search="onSearch"
|
||||
@add-to-queue="onAddToQueue"
|
||||
@play-next="onPlayNext"
|
||||
@select-song="onSelectSong"
|
||||
/>
|
||||
</section>
|
||||
<section class="right-pane">
|
||||
<RightPanel :selected-song="selectedSong" :loading="detailLoading" :playback-state="playbackState" :user-id="userId" />
|
||||
</section>
|
||||
</main>
|
||||
<!-- 公告弹窗 -->
|
||||
<div v-if="announcement" class="announcement-modal" @click="announcement = null">
|
||||
<div class="announcement-content" @click.stop>
|
||||
<h3>{{ announcement.title }}</h3>
|
||||
<p>{{ announcement.content }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import LeftPanel from './components/LeftPanel.vue'
|
||||
import RightPanel from './components/RightPanel.vue'
|
||||
import {
|
||||
searchSongs,
|
||||
getSongDetail,
|
||||
getSongUrlV1,
|
||||
getQueue,
|
||||
addToQueue,
|
||||
playNext as playNextAPI,
|
||||
getPlaybackState
|
||||
} from './api/backend'
|
||||
import { initWebSocket } from './api/websocket'
|
||||
|
||||
const keyword = ref('')
|
||||
const loading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const searchResults = ref([])
|
||||
const queueList = ref([])
|
||||
const selectedSong = ref(null)
|
||||
const playbackState = ref(null)
|
||||
const onlineCount = ref(0)
|
||||
const isFullscreen = ref(false)
|
||||
const userId = ref('')
|
||||
|
||||
// 加载播放队列
|
||||
async function loadQueue() {
|
||||
try {
|
||||
queueList.value = await getQueue()
|
||||
} catch (error) {
|
||||
console.error('加载队列失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// WebSocket队列更新回调
|
||||
function onQueueUpdate(queue) {
|
||||
queueList.value = queue
|
||||
}
|
||||
|
||||
// WebSocket播放状态更新回调
|
||||
function onPlaybackStateUpdate(state) {
|
||||
playbackState.value = state
|
||||
}
|
||||
|
||||
// WebSocket在线人数更新回调
|
||||
function onOnlineCountUpdate(count) {
|
||||
onlineCount.value = count
|
||||
}
|
||||
|
||||
// WebSocket公告回调
|
||||
const announcement = ref(null)
|
||||
function onAnnouncement(data) {
|
||||
announcement.value = data
|
||||
// 显示公告弹窗
|
||||
setTimeout(() => {
|
||||
announcement.value = null
|
||||
}, data.duration || 5000)
|
||||
}
|
||||
|
||||
// WebSocket音量控制回调
|
||||
function onVolumeControl(data) {
|
||||
console.log('收到WebSocket音量控制消息:', data)
|
||||
// 触发自定义事件,让RightPanel监听并更新音量
|
||||
window.dispatchEvent(new CustomEvent('admin-volume-control', { detail: data }))
|
||||
}
|
||||
|
||||
// WebSocket用户ID回调
|
||||
function onUserId(id) {
|
||||
userId.value = id
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadQueue()
|
||||
// 加载当前播放状态
|
||||
try {
|
||||
playbackState.value = await getPlaybackState()
|
||||
} catch (error) {
|
||||
console.error('加载播放状态失败:', error)
|
||||
}
|
||||
// 初始化WebSocket连接
|
||||
initWebSocket(onQueueUpdate, onPlaybackStateUpdate, onOnlineCountUpdate, onAnnouncement, onVolumeControl, onUserId)
|
||||
|
||||
// 监听全屏状态变化
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange)
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange)
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange)
|
||||
|
||||
// 初始化全屏状态
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
// 清理WebSocket连接会在websocket.js中处理
|
||||
|
||||
// 移除全屏状态监听
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange)
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange)
|
||||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange)
|
||||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange)
|
||||
})
|
||||
|
||||
function onUpdateKeyword(val) {
|
||||
keyword.value = val
|
||||
}
|
||||
|
||||
async function onSearch() {
|
||||
loading.value = true
|
||||
try {
|
||||
const results = await searchSongs(keyword.value)
|
||||
searchResults.value = results
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error)
|
||||
searchResults.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onAddToQueue(song) {
|
||||
try {
|
||||
await addToQueue(song)
|
||||
// WebSocket会自动更新队列,不需要手动重新加载
|
||||
} catch (error) {
|
||||
console.error('添加到队列失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlayNext() {
|
||||
try {
|
||||
const result = await playNextAPI()
|
||||
if (result.song) {
|
||||
await onSelectSong(result.song)
|
||||
// WebSocket会自动更新队列,不需要手动重新加载
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放下一首失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function onSelectSong(song) {
|
||||
selectedSong.value = null
|
||||
detailLoading.value = true
|
||||
try {
|
||||
const detail = await getSongDetail(song.id)
|
||||
// 不再需要获取播放URL,后端会自动通过流代理
|
||||
selectedSong.value = detail
|
||||
} catch (error) {
|
||||
console.error('获取歌曲详情失败:', error)
|
||||
} finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新页面
|
||||
function handleRefresh() {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
// 全屏/退出全屏
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
// 进入全屏
|
||||
document.documentElement.requestFullscreen().then(() => {
|
||||
isFullscreen.value = true
|
||||
}).catch((err) => {
|
||||
console.error('进入全屏失败:', err)
|
||||
})
|
||||
} else {
|
||||
// 退出全屏
|
||||
document.exitFullscreen().then(() => {
|
||||
isFullscreen.value = false
|
||||
}).catch((err) => {
|
||||
console.error('退出全屏失败:', err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 监听全屏状态变化
|
||||
function handleFullscreenChange() {
|
||||
isFullscreen.value = !!document.fullscreenElement
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
padding: 12px 16px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-id {
|
||||
font-size: 14px;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.online-count {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.fullscreen-btn,
|
||||
.refresh-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.fullscreen-btn:hover,
|
||||
.refresh-btn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
|
||||
.fullscreen-btn:active,
|
||||
.refresh-btn:active {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: grid;
|
||||
grid-template-columns: 420px 1fr;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
flex: 1;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.left-pane {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-pane {
|
||||
background: #ffffff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.announcement-modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 10000;
|
||||
animation: fadeIn 0.3s;
|
||||
}
|
||||
|
||||
.announcement-content {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
animation: slideUp 0.3s;
|
||||
}
|
||||
|
||||
.announcement-content h3 {
|
||||
margin: 0 0 12px 0;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.announcement-content p {
|
||||
margin: 0;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
transform: translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// 管理员API
|
||||
const ADMIN_BASE_URL = ''
|
||||
|
||||
function getAdminToken() {
|
||||
return localStorage.getItem('admin_token') || ''
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const token = getAdminToken()
|
||||
|
||||
const response = await fetch(`${ADMIN_BASE_URL}${url}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// token无效,清除并跳转登录
|
||||
localStorage.removeItem('admin_token')
|
||||
if (window.location.pathname !== '/admin/login') {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
}
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员登录
|
||||
*/
|
||||
export async function adminLogin(password) {
|
||||
return await request('/api/admin/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放
|
||||
*/
|
||||
export async function adminPlay() {
|
||||
return await request('/api/admin/playback/play', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停
|
||||
*/
|
||||
export async function adminPause() {
|
||||
return await request('/api/admin/playback/pause', {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送公告
|
||||
*/
|
||||
export async function adminSendAnnouncement(announcement) {
|
||||
return await request('/api/admin/announcement', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(announcement),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置特定用户的音量
|
||||
*/
|
||||
export async function adminSetUserVolume(userId, volume, muted) {
|
||||
// 确保userId是字符串
|
||||
const userIdStr = String(userId)
|
||||
console.log('调用adminSetUserVolume:', userIdStr, volume, muted)
|
||||
return await request(`/api/admin/user/${encodeURIComponent(userIdStr)}/volume`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ volume, muted }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置特定用户的静音状态
|
||||
*/
|
||||
export async function adminSetUserMute(userId, muted) {
|
||||
// 确保userId是字符串
|
||||
const userIdStr = String(userId)
|
||||
console.log('调用adminSetUserMute:', userIdStr, muted)
|
||||
return await request(`/api/admin/user/${encodeURIComponent(userIdStr)}/mute`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ muted }),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取播放状态
|
||||
*/
|
||||
export async function adminGetPlaybackState() {
|
||||
return await request('/api/admin/playback/state', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有在线用户
|
||||
*/
|
||||
export async function adminGetAllUsers() {
|
||||
return await request('/api/admin/users', {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 调整播放进度
|
||||
*/
|
||||
export async function adminSeek(time) {
|
||||
return await request('/api/admin/playback/seek', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ time }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
// Midway后端API
|
||||
// 使用相对路径,通过Vite代理转发到后端
|
||||
const BASE_URL = ''
|
||||
|
||||
async function request(path, options = {}) {
|
||||
// 使用相对路径,Vite会通过代理转发到后端
|
||||
const url = path.startsWith('/') ? path : `/${path}`
|
||||
const { method = 'GET', body, params = {} } = options
|
||||
|
||||
// 构建URL,添加查询参数
|
||||
let requestUrl = url
|
||||
if (method === 'GET' && params && Object.keys(params).length > 0) {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && v !== '') {
|
||||
searchParams.append(k, v)
|
||||
}
|
||||
})
|
||||
const queryString = searchParams.toString()
|
||||
if (queryString) {
|
||||
requestUrl += `?${queryString}`
|
||||
}
|
||||
}
|
||||
|
||||
const config = {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
|
||||
if (body && method !== 'GET') {
|
||||
config.body = JSON.stringify(body)
|
||||
}
|
||||
|
||||
const res = await fetch(requestUrl, config)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(data.message || '请求失败')
|
||||
}
|
||||
|
||||
return data.data
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索歌曲
|
||||
*/
|
||||
export async function searchSongs(keywords, limit = 20) {
|
||||
return await request('/api/music/search', {
|
||||
method: 'GET',
|
||||
params: { keywords, limit }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲详情
|
||||
*/
|
||||
export async function getSongDetail(id) {
|
||||
return await request(`/api/music/song/${id}/detail`, {
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌曲播放URL
|
||||
*/
|
||||
export async function getSongUrlV1(id, level = 'standard') {
|
||||
const data = await request(`/api/music/song/${id}/url`, {
|
||||
method: 'GET',
|
||||
params: { level }
|
||||
})
|
||||
return data.url || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取播放队列
|
||||
*/
|
||||
export async function getQueue() {
|
||||
return await request('/api/music/queue', {
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加到播放队列
|
||||
*/
|
||||
export async function addToQueue(song) {
|
||||
const result = await request('/api/music/queue/add', {
|
||||
method: 'POST',
|
||||
body: { song }
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放下一首
|
||||
*/
|
||||
export async function playNext() {
|
||||
const result = await request('/api/music/queue/play-next', {
|
||||
method: 'POST'
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空队列
|
||||
*/
|
||||
export async function clearQueue() {
|
||||
return await request('/api/music/queue/clear', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 从队列移除歌曲
|
||||
*/
|
||||
export async function removeFromQueue(songId) {
|
||||
return await request('/api/music/queue/remove', {
|
||||
method: 'POST',
|
||||
body: { songId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取歌词
|
||||
*/
|
||||
export async function getLyric(id) {
|
||||
const data = await request(`/api/music/song/${id}/lyric`, {
|
||||
method: 'GET'
|
||||
})
|
||||
return data || { raw: '', tlyric: '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置当前播放歌曲
|
||||
*/
|
||||
export async function setPlaybackSong(songId) {
|
||||
return await request('/api/music/playback/set-song', {
|
||||
method: 'POST',
|
||||
body: { songId }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制 - 播放
|
||||
*/
|
||||
export async function play() {
|
||||
return await request('/api/music/playback/play', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制 - 暂停
|
||||
*/
|
||||
export async function pause() {
|
||||
return await request('/api/music/playback/pause', {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放控制 - 跳转时间
|
||||
*/
|
||||
export async function seek(time) {
|
||||
return await request('/api/music/playback/seek', {
|
||||
method: 'POST',
|
||||
body: { time }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前播放状态
|
||||
*/
|
||||
export async function getPlaybackState() {
|
||||
return await request('/api/music/playback/state', {
|
||||
method: 'GET'
|
||||
})
|
||||
}
|
||||
|
||||
export function getBaseUrl() {
|
||||
// 返回空字符串,使用相对路径通过代理
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// WebSocket连接管理
|
||||
// 使用相对路径,通过Vite代理转发到后端
|
||||
// 在开发环境下,Vite会代理WebSocket连接
|
||||
const WS_BASE_URL = import.meta.env.DEV
|
||||
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`
|
||||
: 'ws://localhost:7001'
|
||||
|
||||
let ws = null
|
||||
let reconnectTimer = null
|
||||
let reconnectAttempts = 0
|
||||
const MAX_RECONNECT_ATTEMPTS = 5
|
||||
const RECONNECT_DELAY = 3000
|
||||
|
||||
/**
|
||||
* 获取或创建客户端唯一标识
|
||||
*/
|
||||
function getClientId() {
|
||||
let clientId = localStorage.getItem('client_id')
|
||||
if (!clientId) {
|
||||
// 生成唯一客户端ID(基于时间戳和随机数)
|
||||
clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`
|
||||
localStorage.setItem('client_id', clientId)
|
||||
}
|
||||
return clientId
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化WebSocket连接
|
||||
*/
|
||||
export function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket已经连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取客户端唯一标识
|
||||
const clientId = getClientId()
|
||||
const wsUrl = `${WS_BASE_URL}/ws?clientId=${encodeURIComponent(clientId)}`
|
||||
console.log('正在连接WebSocket:', wsUrl)
|
||||
|
||||
try {
|
||||
ws = new WebSocket(wsUrl)
|
||||
|
||||
ws.onopen = () => {
|
||||
console.log('WebSocket连接已建立')
|
||||
reconnectAttempts = 0
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data)
|
||||
if (data.type === 'queue_update') {
|
||||
onQueueUpdate(data.data || [])
|
||||
} else if (data.type === 'playback_state') {
|
||||
if (onPlaybackState) {
|
||||
onPlaybackState(data.data)
|
||||
}
|
||||
} else if (data.type === 'online_count') {
|
||||
if (onOnlineCount) {
|
||||
onOnlineCount(data.data?.count || 0)
|
||||
}
|
||||
} else if (data.type === 'announcement') {
|
||||
if (onAnnouncement) {
|
||||
onAnnouncement(data.data)
|
||||
}
|
||||
} else if (data.type === 'volume_control') {
|
||||
if (onVolumeControl) {
|
||||
onVolumeControl(data.data)
|
||||
}
|
||||
} else if (data.type === 'user_id') {
|
||||
if (onUserId) {
|
||||
onUserId(data.data?.userId)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('解析WebSocket消息失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket错误:', error)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log('WebSocket连接已关闭')
|
||||
ws = null
|
||||
|
||||
// 尝试重连
|
||||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++
|
||||
console.log(`尝试重连 (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId)
|
||||
}, RECONNECT_DELAY)
|
||||
} else {
|
||||
console.error('WebSocket重连失败,已达到最大重试次数')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建WebSocket连接失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭WebSocket连接
|
||||
*/
|
||||
export function closeWebSocket() {
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = null
|
||||
}
|
||||
|
||||
if (ws) {
|
||||
ws.close()
|
||||
ws = null
|
||||
}
|
||||
|
||||
reconnectAttempts = MAX_RECONNECT_ATTEMPTS // 阻止自动重连
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取WebSocket连接状态
|
||||
*/
|
||||
export function getWebSocketStatus() {
|
||||
if (!ws) return 'disconnected'
|
||||
switch (ws.readyState) {
|
||||
case WebSocket.CONNECTING:
|
||||
return 'connecting'
|
||||
case WebSocket.OPEN:
|
||||
return 'connected'
|
||||
case WebSocket.CLOSING:
|
||||
return 'closing'
|
||||
case WebSocket.CLOSED:
|
||||
return 'closed'
|
||||
default:
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<div class="left-wrap">
|
||||
<div class="block search-block">
|
||||
<div class="block-title">歌曲搜索</div>
|
||||
<div class="search-bar">
|
||||
<input
|
||||
class="input"
|
||||
type="text"
|
||||
:value="keyword"
|
||||
placeholder="输入歌名 / 歌手 回车搜索"
|
||||
@input="$emit('update:keyword', $event.target.value)"
|
||||
@keyup.enter="$emit('search')"
|
||||
/>
|
||||
<button class="btn" :disabled="loading" @click="$emit('search')">
|
||||
{{ loading ? '搜索中...' : '搜索' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="search-results">
|
||||
<template v-if="searchResults && searchResults.length">
|
||||
<div
|
||||
v-for="s in searchResults"
|
||||
:key="s.id"
|
||||
class="result-item"
|
||||
>
|
||||
<div class="meta" @click="$emit('select-song', s)">
|
||||
<div class="title">{{ s.title }}</div>
|
||||
<div class="subtitle">{{ s.artist }} · {{ s.album }}</div>
|
||||
</div>
|
||||
<button class="btn ghost" @click="$emit('add-to-queue', s)">加入待播</button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty">暂无结果</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="block queue-block">
|
||||
<div class="block-title with-action">
|
||||
<span>待播放列表 ({{ queueList.length }})</span>
|
||||
<button class="btn small" @click="$emit('play-next')">播放下一首</button>
|
||||
</div>
|
||||
<div class="list">
|
||||
<template v-if="queueList && queueList.length">
|
||||
<div class="list-item" v-for="s in queueList" :key="s.id">
|
||||
<div class="title">{{ s.title }}</div>
|
||||
<div class="subtitle">{{ s.artist }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="empty">暂无待播放歌曲</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
defineProps({
|
||||
keyword: { type: String, default: '' },
|
||||
loading: { type: Boolean, default: false },
|
||||
searchResults: { type: Array, default: () => [] },
|
||||
queueList: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
function formatTime(iso) {
|
||||
if (!iso) return ''
|
||||
const d = new Date(iso)
|
||||
const hh = String(d.getHours()).padStart(2, '0')
|
||||
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||
const ss = String(d.getSeconds()).padStart(2, '0')
|
||||
return `${hh}:${mm}:${ss}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.left-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.block {
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
.block:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.block-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.block-title.with-action {
|
||||
gap: 8px;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.btn.small {
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn.ghost {
|
||||
background: #f8fafc;
|
||||
}
|
||||
.search-results {
|
||||
margin-top: 10px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border: 1px dashed #e5e7eb;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.result-item + .result-item {
|
||||
border-top: 1px dashed #e5e7eb;
|
||||
}
|
||||
.meta {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.title { font-weight: 600; }
|
||||
.subtitle { color: #64748b; font-size: 12px; }
|
||||
.list {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-item {
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.list-item + .list-item { border-top: 1px solid #f1f5f9; }
|
||||
.empty {
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
<template>
|
||||
<div class="detail-wrap">
|
||||
<template v-if="loading">
|
||||
<div class="placeholder">加载中...</div>
|
||||
</template>
|
||||
<template v-else-if="!currentSong">
|
||||
<div class="placeholder">当前没有歌曲在播放</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="header">
|
||||
<div class="cover" aria-hidden="true">🎵</div>
|
||||
<div class="info">
|
||||
<div class="title">{{ currentSong.title }}</div>
|
||||
<div class="subtitle">{{ currentSong.artist }} · {{ currentSong.album }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="row"><span class="label">时长</span><span class="value">{{ formatDuration(currentSong.durationSec) }}</span></div>
|
||||
<div class="row"><span class="label">简介</span><span class="value">{{ currentSong.description || '暂无简介' }}</span></div>
|
||||
</div>
|
||||
<div class="player" v-if="streamUrl">
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="streamUrl"
|
||||
style="width:100%; pointer-events: none;"
|
||||
@timeupdate="onTimeUpdate"
|
||||
></audio>
|
||||
<div class="player-info">
|
||||
<span class="time-display">{{ formatTime(playbackState?.currentTime || 0) }} / {{ formatTime(currentSong.durationSec || 0) }}</span>
|
||||
<span class="status">{{ playbackState?.isPlaying ? '播放中' : '已暂停' }}</span>
|
||||
</div>
|
||||
<div class="volume-control">
|
||||
<button
|
||||
class="mute-btn"
|
||||
@click="toggleMute"
|
||||
:title="isMuted ? '取消静音' : '静音'"
|
||||
>
|
||||
{{ isMuted ? '🔇' : '🔊' }}
|
||||
</button>
|
||||
<div class="volume-slider-wrap">
|
||||
<input
|
||||
type="range"
|
||||
class="volume-slider"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="volume * 100"
|
||||
@input="onVolumeChange"
|
||||
/>
|
||||
<span class="volume-value">{{ Math.round(volume * 100) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="lyrics" v-if="lyrics.length">
|
||||
<div class="lyrics-scroll" ref="lyricScrollRef" :style="lyricScrollStyle">
|
||||
<div
|
||||
v-for="(item, idx) in windowLyrics"
|
||||
:key="`${item.i}-${item.line.timeSec}`"
|
||||
:class="['lyric-line', { active: item.i === displayIndex }]"
|
||||
ref="setLineRef"
|
||||
>
|
||||
{{ item.line.text }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, ref, watch, nextTick, toRefs, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { getLyric } from '../api/backend'
|
||||
import { parseLrc } from '../utils/lrc'
|
||||
|
||||
const props = defineProps({
|
||||
selectedSong: { type: Object, default: null },
|
||||
loading: { type: Boolean, default: false },
|
||||
playbackState: { type: Object, default: null },
|
||||
userId: { type: String, default: '' }
|
||||
})
|
||||
const { selectedSong, playbackState, userId } = toRefs(props)
|
||||
|
||||
// 当前用户ID(用于匹配音量控制消息)
|
||||
const currentUserId = computed(() => userId.value)
|
||||
|
||||
// 当前播放的歌曲
|
||||
const currentSong = computed(() => {
|
||||
return playbackState.value?.song || selectedSong.value
|
||||
})
|
||||
|
||||
// 流URL(后端代理)
|
||||
const streamUrl = computed(() => {
|
||||
if (!currentSong.value?.id) return null
|
||||
return `/api/music/stream/${currentSong.value.id}?level=standard`
|
||||
})
|
||||
|
||||
function formatDuration(sec) {
|
||||
if (!sec && sec !== 0) return '-'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = sec % 60
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatTime(sec) {
|
||||
if (!sec && sec !== 0) return '0:00'
|
||||
const m = Math.floor(sec / 60)
|
||||
const s = Math.floor(sec % 60)
|
||||
return `${m}:${String(s).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const audioRef = ref(null)
|
||||
const lyrics = ref([])
|
||||
const activeIndex = ref(-1)
|
||||
const lyricScrollRef = ref(null)
|
||||
const lineEls = []
|
||||
const isSyncing = ref(false) // 防止同步循环
|
||||
function setLineRef(el) { if (el) lineEls.push(el) }
|
||||
|
||||
// 音量控制
|
||||
const volume = ref(0.5) // 默认音量50%
|
||||
const isMuted = ref(true) // 默认静音
|
||||
|
||||
// 从localStorage加载音量设置
|
||||
function loadVolumeSettings() {
|
||||
try {
|
||||
const savedVolume = localStorage.getItem('music-player-volume')
|
||||
const savedMuted = localStorage.getItem('music-player-muted')
|
||||
if (savedVolume !== null) {
|
||||
volume.value = parseFloat(savedVolume)
|
||||
}
|
||||
if (savedMuted !== null) {
|
||||
isMuted.value = savedMuted === 'true'
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('加载音量设置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存音量设置到localStorage
|
||||
function saveVolumeSettings() {
|
||||
try {
|
||||
localStorage.setItem('music-player-volume', volume.value.toString())
|
||||
localStorage.setItem('music-player-muted', isMuted.value.toString())
|
||||
} catch (e) {
|
||||
console.warn('保存音量设置失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 应用音量设置到audio元素
|
||||
function applyVolumeSettings() {
|
||||
if (audioRef.value) {
|
||||
console.log('应用音量设置到audio元素:', 'volume:', volume.value, 'muted:', isMuted.value)
|
||||
audioRef.value.volume = volume.value
|
||||
audioRef.value.muted = isMuted.value
|
||||
console.log('audio元素音量已更新:', audioRef.value.volume, '静音:', audioRef.value.muted)
|
||||
} else {
|
||||
console.warn('audioRef.value为空,无法应用音量设置')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换静音
|
||||
function toggleMute() {
|
||||
isMuted.value = !isMuted.value
|
||||
saveVolumeSettings()
|
||||
applyVolumeSettings()
|
||||
}
|
||||
|
||||
// 音量滑块变化
|
||||
function onVolumeChange(e) {
|
||||
volume.value = parseFloat(e.target.value) / 100
|
||||
// 如果音量大于0,自动取消静音
|
||||
if (volume.value > 0 && isMuted.value) {
|
||||
isMuted.value = false
|
||||
}
|
||||
// 如果音量设为0,自动静音
|
||||
if (volume.value === 0 && !isMuted.value) {
|
||||
isMuted.value = true
|
||||
}
|
||||
saveVolumeSettings()
|
||||
applyVolumeSettings()
|
||||
}
|
||||
|
||||
const displayIndex = computed(() => {
|
||||
let i = activeIndex.value
|
||||
while (i > 0) {
|
||||
const t = lyrics.value[i]
|
||||
if (t && String(t.text || '').trim().length > 0) break
|
||||
i -= 1
|
||||
}
|
||||
return i
|
||||
})
|
||||
|
||||
const windowLyrics = computed(() => {
|
||||
const result = []
|
||||
const center = displayIndex.value
|
||||
if (center < 0 || !lyrics.value.length) return result
|
||||
// gather previous 3 non-empty
|
||||
let prevCount = 0
|
||||
for (let i = center - 1; i >= 0 && prevCount < 3; i--) {
|
||||
const line = lyrics.value[i]
|
||||
if (String(line?.text || '').trim().length === 0) continue
|
||||
result.unshift({ i, line })
|
||||
prevCount++
|
||||
}
|
||||
// center line (can be empty -> we still use displayIndex ensured non-empty)
|
||||
const centerLine = lyrics.value[center]
|
||||
result.push({ i: center, line: centerLine })
|
||||
// gather next 3 non-empty
|
||||
let nextCount = 0
|
||||
for (let i = center + 1; i < lyrics.value.length && nextCount < 3; i++) {
|
||||
const line = lyrics.value[i]
|
||||
if (String(line?.text || '').trim().length === 0) continue
|
||||
result.push({ i, line })
|
||||
nextCount++
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const MAX_LYRIC_HEIGHT = 260
|
||||
const contentHeight = ref(0)
|
||||
const lyricScrollStyle = computed(() => {
|
||||
const max = MAX_LYRIC_HEIGHT
|
||||
const h = contentHeight.value > 0 ? Math.min(contentHeight.value, max) : 'auto'
|
||||
const maxHeight = typeof h === 'number' ? `${h}px` : h
|
||||
const overflow = contentHeight.value > max ? 'auto' : 'hidden'
|
||||
return { maxHeight, overflow }
|
||||
})
|
||||
|
||||
function measureLyricHeight() {
|
||||
const total = lineEls.reduce((sum, el) => sum + el.offsetHeight, 0)
|
||||
contentHeight.value = total
|
||||
}
|
||||
|
||||
// 监听播放状态变化,同步到本地播放器(只读模式,不触发播放/暂停)
|
||||
watch(() => playbackState.value, (state) => {
|
||||
if (!state || !audioRef.value || isSyncing.value) return
|
||||
|
||||
isSyncing.value = true
|
||||
|
||||
// 同步播放时间(仅在差异较大时同步,避免频繁跳转)
|
||||
const timeDiff = Math.abs(audioRef.value.currentTime - state.currentTime)
|
||||
if (timeDiff > 0.5) {
|
||||
// 只在差异大于0.5秒时才同步,避免频繁跳转影响播放
|
||||
audioRef.value.currentTime = state.currentTime
|
||||
}
|
||||
|
||||
// 同步播放/暂停状态(只同步,不触发后端API)
|
||||
// 注意:这里只同步显示状态,不会影响后端播放
|
||||
// 新用户连接时,会根据后端状态自动同步,但不会暂停后端播放
|
||||
if (state.isPlaying && audioRef.value.paused) {
|
||||
// 如果后端正在播放但本地暂停,则尝试播放(仅本地同步)
|
||||
// 即使播放失败(浏览器策略限制),也不影响后端播放
|
||||
audioRef.value.play().catch((err) => {
|
||||
// 忽略播放错误,这是正常的浏览器行为
|
||||
// 前端可能无法自动播放,但后端继续播放不受影响
|
||||
})
|
||||
} else if (!state.isPlaying && !audioRef.value.paused) {
|
||||
// 如果后端暂停但本地播放,则暂停(仅本地同步)
|
||||
audioRef.value.pause()
|
||||
}
|
||||
|
||||
// 同步歌词进度(基于后端推送的时间)
|
||||
if (lyrics.value.length) {
|
||||
const t = state.currentTime
|
||||
let idx = lyrics.value.findIndex((l, i) => {
|
||||
const next = lyrics.value[i + 1]
|
||||
return t >= l.timeSec && (!next || t < next.timeSec)
|
||||
})
|
||||
if (idx === -1 && t > lyrics.value[lyrics.value.length - 1].timeSec) {
|
||||
idx = lyrics.value.length - 1
|
||||
}
|
||||
if (idx !== -1 && idx !== activeIndex.value) {
|
||||
activeIndex.value = idx
|
||||
nextTick(() => scrollToActive(false))
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
isSyncing.value = false
|
||||
}, 100)
|
||||
}, { deep: true })
|
||||
|
||||
// 监听歌曲变化(只加载歌词,不控制播放)
|
||||
watch(() => currentSong.value?.id, async (id) => {
|
||||
lyrics.value = []
|
||||
activeIndex.value = -1
|
||||
lineEls.length = 0
|
||||
if (!id) return
|
||||
|
||||
// 只加载歌词,播放控制完全由后端管理
|
||||
// 当后端播放状态变化时,会自动通过WebSocket更新
|
||||
try {
|
||||
const { raw } = await getLyric(id)
|
||||
lyrics.value = parseLrc(raw)
|
||||
await nextTick()
|
||||
measureLyricHeight()
|
||||
scrollToActive(true)
|
||||
} catch (e) {
|
||||
lyrics.value = []
|
||||
}
|
||||
})
|
||||
|
||||
// 时间更新事件(仅用于本地歌词显示,不发送到后端)
|
||||
function onTimeUpdate(e) {
|
||||
// 前端播放器的时间更新仅用于本地歌词高亮
|
||||
// 实际的播放进度由后端通过WebSocket推送
|
||||
if (isSyncing.value) return
|
||||
const t = e.target.currentTime || 0
|
||||
if (!lyrics.value.length) return
|
||||
let idx = lyrics.value.findIndex((l, i) => {
|
||||
const next = lyrics.value[i + 1]
|
||||
return t >= l.timeSec && (!next || t < next.timeSec)
|
||||
})
|
||||
if (idx === -1 && t > lyrics.value[lyrics.value.length - 1].timeSec) {
|
||||
idx = lyrics.value.length - 1
|
||||
}
|
||||
if (idx !== -1 && idx !== activeIndex.value) {
|
||||
activeIndex.value = idx
|
||||
nextTick(() => scrollToActive(false))
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToActive(immediate) {
|
||||
const wrap = lyricScrollRef.value
|
||||
const globalIdx = displayIndex.value
|
||||
const localIdx = windowLyrics.value.findIndex(it => it.i === globalIdx)
|
||||
if (!wrap || localIdx < 0 || localIdx >= lineEls.length) return
|
||||
const el = lineEls[localIdx]
|
||||
let desiredTop = el.offsetTop - (wrap.clientHeight - el.clientHeight) / 2
|
||||
const maxTop = Math.max(0, wrap.scrollHeight - wrap.clientHeight)
|
||||
if (desiredTop < 0) desiredTop = 0
|
||||
if (desiredTop > maxTop) desiredTop = maxTop
|
||||
wrap.scrollTo({ top: desiredTop, behavior: immediate ? 'auto' : 'smooth' })
|
||||
}
|
||||
|
||||
function handleResize() { scrollToActive(true) }
|
||||
let ro
|
||||
onMounted(() => {
|
||||
// 加载音量设置
|
||||
loadVolumeSettings()
|
||||
// 应用音量设置
|
||||
nextTick(() => {
|
||||
applyVolumeSettings()
|
||||
})
|
||||
|
||||
// 监听管理员音量控制事件
|
||||
window.addEventListener('admin-volume-control', handleAdminVolumeControl)
|
||||
|
||||
// 监听userId变化,确保能正确匹配音量控制消息
|
||||
watch(() => currentUserId.value, (newUserId) => {
|
||||
console.log('用户ID已更新:', newUserId)
|
||||
})
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
if ('ResizeObserver' in window) {
|
||||
ro = new ResizeObserver(() => { measureLyricHeight(); scrollToActive(true) })
|
||||
if (lyricScrollRef.value) ro.observe(lyricScrollRef.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听audioRef变化,确保音量设置被应用
|
||||
watch(() => audioRef.value, () => {
|
||||
if (audioRef.value) {
|
||||
applyVolumeSettings()
|
||||
}
|
||||
})
|
||||
|
||||
// 监听管理员音量控制事件
|
||||
function handleAdminVolumeControl(e) {
|
||||
const data = e.detail
|
||||
console.log('收到音量控制消息:', data, '当前用户ID:', currentUserId.value)
|
||||
|
||||
// 检查是否是针对特定用户的控制
|
||||
if (data && data.targetUserId) {
|
||||
// 如果有targetUserId,必须是针对当前用户的
|
||||
if (currentUserId.value) {
|
||||
// 如果已经有用户ID,检查是否匹配
|
||||
if (data.targetUserId !== currentUserId.value) {
|
||||
// 不是针对当前用户,忽略
|
||||
console.log('音量控制消息不是针对当前用户,忽略', data.targetUserId, '!=', currentUserId.value)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// 如果还没有用户ID,暂时忽略(等待用户ID设置后再处理)
|
||||
// 但可以缓存消息,等待用户ID设置后再处理
|
||||
console.log('用户ID还未设置,暂时忽略音量控制消息(等待用户ID)')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 处理音量控制(全局控制或针对当前用户的控制)
|
||||
if (data && typeof data.volume === 'number') {
|
||||
console.log('应用音量控制:', data.volume, 'muted:', data.muted)
|
||||
volume.value = data.volume
|
||||
// 如果指定了muted状态,使用指定值
|
||||
if (typeof data.muted === 'boolean') {
|
||||
isMuted.value = data.muted
|
||||
} else {
|
||||
// 否则根据音量自动判断
|
||||
if (data.volume > 0 && isMuted.value) {
|
||||
isMuted.value = false
|
||||
}
|
||||
if (data.volume === 0 && !isMuted.value) {
|
||||
isMuted.value = true
|
||||
}
|
||||
}
|
||||
saveVolumeSettings()
|
||||
applyVolumeSettings()
|
||||
console.log('音量已更新:', volume.value, '静音:', isMuted.value, 'audio元素:', audioRef.value)
|
||||
} else {
|
||||
console.log('音量控制消息格式错误:', data)
|
||||
}
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('admin-volume-control', handleAdminVolumeControl)
|
||||
if (ro) { try { ro.disconnect() } catch (_) {} ro = null }
|
||||
})
|
||||
|
||||
watch(windowLyrics, () => {
|
||||
lineEls.length = 0
|
||||
nextTick(() => { measureLyricHeight(); scrollToActive(true) })
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-wrap { display: flex; flex-direction: column; gap: 12px; }
|
||||
.placeholder { color: #94a3b8; text-align: center; padding: 24px 0; }
|
||||
.header { display: flex; gap: 12px; align-items: center; }
|
||||
.cover { width: 64px; height: 64px; display: grid; place-items: center; border-radius: 8px; background: #f1f5f9; font-size: 28px; }
|
||||
.info .title { font-size: 20px; font-weight: 700; }
|
||||
.info .subtitle { color: #64748b; }
|
||||
.content { display: grid; grid-template-columns: 120px 1fr; row-gap: 8px; column-gap: 12px; }
|
||||
.row { display: contents; }
|
||||
.label { color: #64748b; }
|
||||
.value { color: #0f172a; }
|
||||
.player { margin-top: 8px; }
|
||||
.player-info { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; padding: 8px; background: #f8fafc; border-radius: 4px; font-size: 14px; }
|
||||
.time-display { color: #64748b; }
|
||||
.status { color: #0f172a; font-weight: 500; }
|
||||
.volume-control { display: flex; align-items: center; gap: 8px; margin-top: 8px; padding: 8px; background: #f8fafc; border-radius: 4px; }
|
||||
.mute-btn { background: none; border: none; cursor: pointer; font-size: 20px; padding: 4px 8px; border-radius: 4px; transition: background-color 0.2s; }
|
||||
.mute-btn:hover { background: #e2e8f0; }
|
||||
.mute-btn:active { background: #cbd5e1; }
|
||||
.volume-slider-wrap { display: flex; align-items: center; gap: 8px; flex: 1; }
|
||||
.volume-slider { flex: 1; height: 4px; background: #e2e8f0; border-radius: 2px; outline: none; cursor: pointer; }
|
||||
.volume-slider::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; background: #3b82f6; border-radius: 50%; cursor: pointer; }
|
||||
.volume-slider::-moz-range-thumb { width: 14px; height: 14px; background: #3b82f6; border-radius: 50%; cursor: pointer; border: none; }
|
||||
.volume-value { color: #64748b; font-size: 12px; min-width: 35px; text-align: right; }
|
||||
.lyrics { border-top: 1px solid #e5e7eb; padding-top: 12px; }
|
||||
.lyrics-scroll {overflow: auto; padding: 0 8px; scrollbar-width: none; -ms-overflow-style: none; }
|
||||
.lyrics-scroll::-webkit-scrollbar { display: none; }
|
||||
.lyric-line { padding: 6px 0; color: #64748b; transition: color .2s, transform .2s; font-size: 30px; line-height: 1.2; }
|
||||
.lyric-line.active { color: #0f172a; transform: scale(1); font-weight: 600; font-size: 35px;}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import AdminLogin from './pages/AdminLogin.vue'
|
||||
import AdminPanel from './pages/AdminPanel.vue'
|
||||
import './style.css'
|
||||
|
||||
// 根据路径决定加载哪个组件
|
||||
const path = window.location.pathname
|
||||
|
||||
let app
|
||||
|
||||
if (path.startsWith('/admin')) {
|
||||
if (path === '/admin/login') {
|
||||
// 管理员登录页面
|
||||
app = createApp(AdminLogin)
|
||||
} else if (path === '/admin') {
|
||||
// 管理员控制面板
|
||||
// 检查token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login'
|
||||
app = createApp(AdminLogin)
|
||||
} else {
|
||||
app = createApp(AdminPanel)
|
||||
}
|
||||
} else {
|
||||
// 其他admin路径,默认跳转到登录页
|
||||
window.location.href = '/admin/login'
|
||||
app = createApp(AdminLogin)
|
||||
}
|
||||
} else {
|
||||
// 主应用
|
||||
app = createApp(App)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<div class="admin-login">
|
||||
<div class="login-card">
|
||||
<h2>管理员登录</h2>
|
||||
<form @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input
|
||||
type="password"
|
||||
v-model="password"
|
||||
placeholder="请输入管理员密码"
|
||||
required
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-login" :disabled="loading">
|
||||
{{ loading ? '登录中...' : '登录' }}
|
||||
</button>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { adminLogin } from '../api/admin'
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const result = await adminLogin(password.value)
|
||||
if (result.success) {
|
||||
// 保存token到localStorage
|
||||
localStorage.setItem('admin_token', result.data.token)
|
||||
// 跳转到管理面板
|
||||
window.location.href = '/admin'
|
||||
} else {
|
||||
error.value = result.message || '登录失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '登录失败,请检查网络连接'
|
||||
console.error('登录错误:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-login {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 30px 0;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
background: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-login {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-login:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-login:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
background: #fee;
|
||||
color: #c33;
|
||||
border-radius: 6px;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,746 @@
|
||||
<template>
|
||||
<div class="admin-panel">
|
||||
<header class="admin-header">
|
||||
<h1>管理员控制面板</h1>
|
||||
<button class="btn-logout" @click="handleLogout">退出登录</button>
|
||||
</header>
|
||||
|
||||
<main class="admin-main">
|
||||
<!-- 播放控制 -->
|
||||
<section class="control-section">
|
||||
<h2>播放控制</h2>
|
||||
<div class="control-group">
|
||||
<button class="btn-control" @click="handlePlay" :disabled="loading">
|
||||
▶️ 播放
|
||||
</button>
|
||||
<button class="btn-control" @click="handlePause" :disabled="loading">
|
||||
⏸️ 暂停
|
||||
</button>
|
||||
<div class="playback-info" v-if="playbackState">
|
||||
<p>当前播放: {{ playbackState.song?.title || '无' }}</p>
|
||||
<p>状态: {{ playbackState.isPlaying ? '播放中' : '已暂停' }}</p>
|
||||
<p v-if="playbackState.duration > 0">
|
||||
进度: {{ formatPlaybackTime(playbackState.currentTime) }} / {{ formatPlaybackTime(playbackState.duration) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 播放进度控制 -->
|
||||
<div v-if="playbackState && playbackState.duration > 0" class="playback-progress">
|
||||
<div class="progress-info">
|
||||
<span>{{ formatPlaybackTime(playbackState.currentTime) }}</span>
|
||||
<span>{{ formatPlaybackTime(playbackState.duration) }}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
class="progress-slider"
|
||||
:min="0"
|
||||
:max="playbackState.duration"
|
||||
:value="playbackState.currentTime"
|
||||
step="0.1"
|
||||
@input="onProgressInput"
|
||||
@change="onProgressChange"
|
||||
/>
|
||||
<div class="progress-actions">
|
||||
<button class="btn-small" @click="seekTo(0)">开始</button>
|
||||
<button class="btn-small" @click="seekTo(playbackState.duration / 4)">25%</button>
|
||||
<button class="btn-small" @click="seekTo(playbackState.duration / 2)">50%</button>
|
||||
<button class="btn-small" @click="seekTo(playbackState.duration * 3 / 4)">75%</button>
|
||||
<button class="btn-small" @click="seekTo(playbackState.duration)">结束</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 公告管理 -->
|
||||
<section class="control-section">
|
||||
<h2>公告管理</h2>
|
||||
<div class="form-group">
|
||||
<label>标题</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="announcementTitle"
|
||||
placeholder="请输入公告标题"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>内容</label>
|
||||
<textarea
|
||||
v-model="announcementContent"
|
||||
placeholder="请输入公告内容"
|
||||
rows="4"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>显示时长(毫秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="announcementDuration"
|
||||
min="1000"
|
||||
max="30000"
|
||||
placeholder="5000"
|
||||
/>
|
||||
</div>
|
||||
<button class="btn-control" @click="handleSendAnnouncement" :disabled="loading">
|
||||
发送公告
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- 在线用户列表 -->
|
||||
<section class="control-section users-section">
|
||||
<h2>在线用户列表 ({{ users.length }})</h2>
|
||||
<div class="users-list">
|
||||
<div v-if="users.length === 0" class="empty-users">暂无在线用户</div>
|
||||
<div v-else class="users-cards">
|
||||
<div v-for="user in users" :key="user.id" class="user-card">
|
||||
<div class="user-info">
|
||||
<div class="user-id-label">用户ID: {{ user.id }}</div>
|
||||
<div class="user-ip-label">IP: {{ user.ip }}</div>
|
||||
<div class="user-time-label">
|
||||
连接: {{ formatTime(user.connectedAt) }} |
|
||||
活跃: {{ formatTime(user.lastActiveAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-controls">
|
||||
<div class="user-volume-control">
|
||||
<label>音量: {{ Math.round((userVolumes[user.id] || 0.5) * 100) }}%</label>
|
||||
<input
|
||||
type="range"
|
||||
:value="userVolumes[user.id] || 0.5"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.01"
|
||||
@input="(e) => handleUserVolumeChange(user.id, parseFloat(e.target.value))"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-volume-buttons">
|
||||
<button
|
||||
class="btn-user-small"
|
||||
@click="toggleUserMute(user.id)"
|
||||
:class="{ active: userMutedStates[user.id] }"
|
||||
>
|
||||
{{ userMutedStates[user.id] ? '🔇 已静音' : '🔊 静音' }}
|
||||
</button>
|
||||
<button class="btn-user-small" @click="setUserVolume(user.id, 0.25)">25%</button>
|
||||
<button class="btn-user-small" @click="setUserVolume(user.id, 0.5)">50%</button>
|
||||
<button class="btn-user-small" @click="setUserVolume(user.id, 0.75)">75%</button>
|
||||
<button class="btn-user-small" @click="setUserVolume(user.id, 1)">100%</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { adminPlay, adminPause, adminSendAnnouncement, adminSetUserVolume, adminSetUserMute, adminGetPlaybackState, adminGetAllUsers, adminSeek } from '../api/admin'
|
||||
import { initWebSocket } from '../api/websocket'
|
||||
const loading = ref(false)
|
||||
const playbackState = ref(null)
|
||||
const announcementTitle = ref('')
|
||||
const announcementContent = ref('')
|
||||
const announcementDuration = ref(5000)
|
||||
const users = ref([])
|
||||
const userVolumes = ref({}) // 存储每个用户的音量 { userId: volume }
|
||||
const userMutedStates = ref({}) // 存储每个用户的静音状态 { userId: muted }
|
||||
|
||||
// 检查token
|
||||
function checkAuth() {
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 加载播放状态
|
||||
async function loadPlaybackState() {
|
||||
try {
|
||||
const result = await adminGetPlaybackState()
|
||||
if (result.success) {
|
||||
playbackState.value = result.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载播放状态失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 加载用户列表
|
||||
async function loadUsers() {
|
||||
try {
|
||||
const result = await adminGetAllUsers()
|
||||
if (result.success) {
|
||||
users.value = result.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化时间(用于用户连接时间)
|
||||
function formatTime(timestamp) {
|
||||
if (!timestamp) return '-'
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) {
|
||||
return '刚刚'
|
||||
} else if (diff < 3600000) {
|
||||
return `${Math.floor(diff / 60000)}分钟前`
|
||||
} else if (diff < 86400000) {
|
||||
return `${Math.floor(diff / 3600000)}小时前`
|
||||
} else {
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化播放时间(秒数转为 MM:SS)
|
||||
function formatPlaybackTime(seconds) {
|
||||
if (!seconds && seconds !== 0) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
return `${mins}:${String(secs).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// 播放
|
||||
async function handlePlay() {
|
||||
if (!checkAuth()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await adminPlay()
|
||||
if (result.success) {
|
||||
playbackState.value = result.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('播放失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停
|
||||
async function handlePause() {
|
||||
if (!checkAuth()) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await adminPause()
|
||||
if (result.success) {
|
||||
playbackState.value = result.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('暂停失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送公告
|
||||
async function handleSendAnnouncement() {
|
||||
if (!checkAuth()) return
|
||||
if (!announcementTitle.value || !announcementContent.value) {
|
||||
alert('请输入标题和内容')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await adminSendAnnouncement({
|
||||
title: announcementTitle.value,
|
||||
content: announcementContent.value,
|
||||
duration: announcementDuration.value,
|
||||
})
|
||||
if (result.success) {
|
||||
alert('公告已发送')
|
||||
announcementTitle.value = ''
|
||||
announcementContent.value = ''
|
||||
} else {
|
||||
alert(result.message || '发送失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送公告失败:', error)
|
||||
alert('发送失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 用户音量滑块变化
|
||||
async function handleUserVolumeChange(userId, volume) {
|
||||
if (!checkAuth()) return
|
||||
userVolumes.value[userId] = volume
|
||||
const muted = volume === 0
|
||||
userMutedStates.value[userId] = muted
|
||||
try {
|
||||
await adminSetUserVolume(userId, volume, muted)
|
||||
} catch (error) {
|
||||
console.error('设置用户音量失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 快捷设置用户音量
|
||||
async function setUserVolume(targetUserId, volume) {
|
||||
if (!checkAuth()) return
|
||||
userVolumes.value[targetUserId] = volume
|
||||
const muted = volume === 0
|
||||
userMutedStates.value[targetUserId] = muted
|
||||
try {
|
||||
await adminSetUserVolume(targetUserId, volume, muted)
|
||||
} catch (error) {
|
||||
console.error('设置用户音量失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换用户静音状态
|
||||
async function toggleUserMute(targetUserId) {
|
||||
if (!checkAuth()) return
|
||||
const currentMuted = userMutedStates.value[targetUserId] || false
|
||||
const newMuted = !currentMuted
|
||||
userMutedStates.value[targetUserId] = newMuted
|
||||
|
||||
// 如果静音,音量设为0;如果取消静音,恢复之前的音量或默认0.5
|
||||
if (newMuted) {
|
||||
userVolumes.value[targetUserId] = 0
|
||||
await adminSetUserVolume(targetUserId, 0, true)
|
||||
} else {
|
||||
// 取消静音时,如果没有保存的音量,使用默认值0.5
|
||||
if (!userVolumes.value[targetUserId] || userVolumes.value[targetUserId] === 0) {
|
||||
userVolumes.value[targetUserId] = 0.5
|
||||
}
|
||||
await adminSetUserVolume(targetUserId, userVolumes.value[targetUserId], false)
|
||||
}
|
||||
}
|
||||
|
||||
// 进度条输入(拖动时)
|
||||
let isSeeking = false
|
||||
function onProgressInput(e) {
|
||||
isSeeking = true
|
||||
// 不立即发送,只更新显示
|
||||
}
|
||||
|
||||
// 进度条变化(松开时)
|
||||
async function onProgressChange(e) {
|
||||
if (!checkAuth()) return
|
||||
const time = parseFloat(e.target.value)
|
||||
isSeeking = false
|
||||
try {
|
||||
await adminSeek(time)
|
||||
// WebSocket会自动更新播放状态
|
||||
} catch (error) {
|
||||
console.error('调整播放进度失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 跳转到指定时间
|
||||
async function seekTo(time) {
|
||||
if (!checkAuth()) return
|
||||
try {
|
||||
await adminSeek(time)
|
||||
// WebSocket会自动更新播放状态
|
||||
} catch (error) {
|
||||
console.error('跳转失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 退出登录
|
||||
function handleLogout() {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
|
||||
// WebSocket播放状态更新
|
||||
function onPlaybackStateUpdate(state) {
|
||||
playbackState.value = state
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!checkAuth()) return
|
||||
|
||||
// 加载播放状态
|
||||
loadPlaybackState()
|
||||
|
||||
// 加载用户列表
|
||||
loadUsers()
|
||||
|
||||
// 初始化WebSocket(只监听播放状态)
|
||||
initWebSocket(() => {}, onPlaybackStateUpdate, () => {}, () => {}, () => {}, () => {})
|
||||
|
||||
// 定期刷新播放状态和用户列表
|
||||
const interval = setInterval(() => {
|
||||
loadPlaybackState()
|
||||
loadUsers()
|
||||
}, 2000)
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(interval)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-panel {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.admin-header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-logout {
|
||||
padding: 8px 16px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-logout:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.control-section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn-control {
|
||||
padding: 10px 20px;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-control:hover:not(:disabled) {
|
||||
background: #5568d3;
|
||||
}
|
||||
|
||||
.btn-control:disabled {
|
||||
background: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.playback-info {
|
||||
margin-left: 20px;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.playback-info p {
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.playback-progress {
|
||||
margin-top: 16px;
|
||||
padding: 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.progress-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.progress-slider::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.progress-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
.volume-control {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.volume-control input[type="range"] {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.volume-buttons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 6px 12px;
|
||||
background: #e5e7eb;
|
||||
color: #333;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-small:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.users-list {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.empty-users {
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.users-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.users-table th,
|
||||
.users-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.users-table th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.users-table tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.users-section {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.users-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 16px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-card {
|
||||
background: #f8fafc;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
margin-bottom: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.user-id-label {
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-ip-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.user-time-label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.user-controls {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.user-volume-control {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.user-volume-control label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.user-volume-control input[type="range"] {
|
||||
width: 100%;
|
||||
-webkit-appearance: none;
|
||||
height: 6px;
|
||||
background: #e2e8f0;
|
||||
border-radius: 3px;
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.user-volume-control input[type="range"]::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-volume-control input[type="range"]::-moz-range-thumb {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: #3b82f6;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.user-volume-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-user-small {
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
padding: 5px 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-user-small:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.btn-user-small.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
// 简单的路由系统
|
||||
export function initRouter() {
|
||||
const path = window.location.pathname
|
||||
|
||||
// 管理员相关路由
|
||||
if (path.startsWith('/admin')) {
|
||||
if (path === '/admin/login') {
|
||||
return 'admin-login'
|
||||
} else if (path === '/admin') {
|
||||
// 检查token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
window.location.href = '/admin/login'
|
||||
return 'admin-login'
|
||||
}
|
||||
return 'admin-panel'
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回主应用
|
||||
return 'main'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #ffffff;
|
||||
--fg: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #e5e7eb;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #app { height: 100%; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, PingFang SC, Microsoft YaHei, Noto Sans, Helvetica Neue, Arial, "Noto Sans CJK", "Source Han Sans SC", "WenQuanYi Micro Hei", sans-serif;
|
||||
color: var(--fg);
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
button { color: var(--fg); }
|
||||
input { color: var(--fg); background: var(--bg); }
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export function parseLrc(raw) {
|
||||
if (!raw) return []
|
||||
const lines = raw.split(/\r?\n/)
|
||||
const entries = []
|
||||
const timeTag = /\[(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\]/g
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue
|
||||
let match
|
||||
const text = line.replace(timeTag, '').trim()
|
||||
timeTag.lastIndex = 0
|
||||
while ((match = timeTag.exec(line)) !== null) {
|
||||
const m = parseInt(match[1] || '0', 10)
|
||||
const s = parseInt(match[2] || '0', 10)
|
||||
const ms = parseInt((match[3] || '0').padEnd(3, '0'), 10)
|
||||
const t = m * 60 + s + ms / 1000
|
||||
entries.push({ timeSec: t, text })
|
||||
}
|
||||
}
|
||||
entries.sort((a, b) => a.timeSec - b.timeSec)
|
||||
return entries
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user