This commit is contained in:
CNLuminous
2025-11-04 15:15:28 +08:00
commit a6af28e4c5
719 changed files with 570630 additions and 0 deletions
+377
View File
@@ -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>
+125
View File
@@ -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 }),
})
}
+187
View File
@@ -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 ''
}
+139
View File
@@ -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'
}
}
+168
View File
@@ -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>
+454
View File
@@ -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>
+38
View File
@@ -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')
+142
View File
@@ -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>
+746
View File
@@ -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>
+23
View File
@@ -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'
}
+21
View File
@@ -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); }
+23
View File
@@ -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
}