完成项目
This commit is contained in:
parent
a6af28e4c5
commit
ba9d40844b
|
|
@ -0,0 +1,7 @@
|
|||
node_modules
|
||||
dist
|
||||
release
|
||||
.DS_Store
|
||||
*.log
|
||||
.vite
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
# 使用淘宝镜像源
|
||||
registry=https://registry.npmmirror.com
|
||||
# Electron 镜像
|
||||
electron_mirror=https://npmmirror.com/mirrors/electron/
|
||||
# Electron Builder 缓存镜像
|
||||
electron_builder_binaries_mirror=https://npmmirror.com/mirrors/electron-builder-binaries/
|
||||
# Node.js 二进制文件镜像
|
||||
disturl=https://npmmirror.com/mirrors/node/
|
||||
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# Electron 打包说明
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
cd webapp
|
||||
npm install
|
||||
```
|
||||
|
||||
## 开发模式
|
||||
|
||||
### 1. 启动前端开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 2. 启动 Electron(新终端窗口)
|
||||
|
||||
```bash
|
||||
npm run electron:dev
|
||||
```
|
||||
|
||||
## 打包为 Windows EXE
|
||||
|
||||
```bash
|
||||
npm run electron:build:win
|
||||
```
|
||||
|
||||
打包后的文件会在 `release` 目录下。
|
||||
|
||||
## 配置后端 API 地址
|
||||
|
||||
1. 在应用右上角点击 ⚙️ 设置按钮
|
||||
2. 输入后端服务器的完整地址(例如:`http://192.168.1.100:7001`)
|
||||
3. 点击"保存设置"
|
||||
4. 刷新页面以应用新设置
|
||||
|
||||
## 注意事项
|
||||
|
||||
- Electron 应用使用 `electron-store` 存储配置,配置文件保存在用户数据目录
|
||||
- Web 版本使用 `localStorage` 存储配置
|
||||
- 设置页面支持测试连接功能,可以验证后端服务器是否可访问
|
||||
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||
const path = require('path');
|
||||
|
||||
// 动态导入 electron-store(ES 模块)
|
||||
let store;
|
||||
let storePromise = import('electron-store').then((StoreModule) => {
|
||||
const Store = StoreModule.default;
|
||||
store = new Store();
|
||||
return store;
|
||||
});
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
|
||||
let mainWindow;
|
||||
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
webSecurity: false, // 允许跨域请求(用于访问后端 API)
|
||||
},
|
||||
// icon: path.join(__dirname, '../assets/icon.png'), // 可选:应用图标(需要提供图标文件)
|
||||
});
|
||||
|
||||
// 加载应用
|
||||
if (isDev) {
|
||||
// 开发环境:加载 Vite 开发服务器
|
||||
mainWindow.loadURL('http://localhost:5173');
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
// 生产环境:加载打包后的文件
|
||||
const indexPath = path.join(__dirname, '../dist/index.html');
|
||||
mainWindow.loadFile(indexPath);
|
||||
}
|
||||
|
||||
mainWindow.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// 确保 store 已初始化
|
||||
await storePromise;
|
||||
|
||||
createWindow();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
// IPC 处理:获取/设置 API 地址
|
||||
ipcMain.handle('get-api-url', async () => {
|
||||
await storePromise;
|
||||
return store.get('apiUrl', 'http://192.168.1.148:7001');
|
||||
});
|
||||
|
||||
ipcMain.handle('set-api-url', async (event, url) => {
|
||||
await storePromise;
|
||||
store.set('apiUrl', url);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle('get-ws-url', async () => {
|
||||
await storePromise;
|
||||
const apiUrl = store.get('apiUrl', 'http://192.168.1.148:7001');
|
||||
// 将 http:// 转换为 ws://, https:// 转换为 wss://
|
||||
return apiUrl.replace(/^http/, 'ws');
|
||||
});
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 暴露受保护的方法给渲染进程
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
getApiUrl: () => ipcRenderer.invoke('get-api-url'),
|
||||
setApiUrl: (url) => ipcRenderer.invoke('set-api-url', url),
|
||||
getWsUrl: () => ipcRenderer.invoke('get-ws-url'),
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -2,18 +2,60 @@
|
|||
"name": "web-karaoke-station",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"description": "Web点歌台 - 在线音乐点播系统",
|
||||
"author": "Web点歌台",
|
||||
"type": "module",
|
||||
"main": "electron/main.cjs",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"electron:dev": "cross-env NODE_ENV=development ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ electron electron/main.cjs",
|
||||
"electron:build": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run build && electron-builder",
|
||||
"electron:build:win": "cross-env CSC_IDENTITY_AUTO_DISCOVERY=false ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ npm run build && electron-builder --win"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0"
|
||||
"vue": "^3.4.0",
|
||||
"electron-store": "^10.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"vite": "^5.0.0"
|
||||
"vite": "^5.0.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.1",
|
||||
"cross-env": "^7.0.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.webkaraoke.station",
|
||||
"productName": "Web点歌台",
|
||||
"electronDownload": {
|
||||
"mirror": "https://npmmirror.com/mirrors/electron/"
|
||||
},
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*.cjs",
|
||||
"node_modules/**/*"
|
||||
],
|
||||
"win": {
|
||||
"target": [
|
||||
{
|
||||
"target": "nsis",
|
||||
"arch": [
|
||||
"x64"
|
||||
]
|
||||
}
|
||||
],
|
||||
"icon": "assets/icon.ico"
|
||||
},
|
||||
"nsis": {
|
||||
"oneClick": false,
|
||||
"allowToChangeInstallationDirectory": true,
|
||||
"createDesktopShortcut": true,
|
||||
"createStartMenuShortcut": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@
|
|||
<button class="refresh-btn" @click="handleRefresh" title="刷新页面">
|
||||
🔄
|
||||
</button>
|
||||
<button class="settings-btn" @click="handleSettings" title="设置">
|
||||
⚙️
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
|
|
@ -125,8 +128,10 @@ onMounted(async () => {
|
|||
} catch (error) {
|
||||
console.error('加载播放状态失败:', error)
|
||||
}
|
||||
// 初始化WebSocket连接
|
||||
initWebSocket(onQueueUpdate, onPlaybackStateUpdate, onOnlineCountUpdate, onAnnouncement, onVolumeControl, onUserId)
|
||||
// 初始化WebSocket连接(使用 async/await)
|
||||
initWebSocket(onQueueUpdate, onPlaybackStateUpdate, onOnlineCountUpdate, onAnnouncement, onVolumeControl, onUserId).catch(err => {
|
||||
console.error('WebSocket初始化失败:', err)
|
||||
})
|
||||
|
||||
// 监听全屏状态变化
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange)
|
||||
|
|
@ -205,6 +210,18 @@ function handleRefresh() {
|
|||
window.location.reload()
|
||||
}
|
||||
|
||||
// 打开设置页面
|
||||
function handleSettings() {
|
||||
// 在 Electron 中使用 hash 路由,在浏览器中使用正常路由
|
||||
if (window.electronAPI) {
|
||||
// Electron 环境:使用 hash 路由
|
||||
window.location.hash = '#/settings'
|
||||
} else {
|
||||
// 浏览器环境:直接跳转
|
||||
window.location.href = '/settings'
|
||||
}
|
||||
}
|
||||
|
||||
// 全屏/退出全屏
|
||||
function toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
|
|
@ -286,15 +303,30 @@ function handleFullscreenChange() {
|
|||
justify-content: center;
|
||||
}
|
||||
|
||||
.fullscreen-btn:hover,
|
||||
.refresh-btn:hover {
|
||||
.fullscreen-btn:hover,
|
||||
.refresh-btn:hover,
|
||||
.settings-btn:hover {
|
||||
background: #f1f5f9;
|
||||
}
|
||||
}
|
||||
|
||||
.fullscreen-btn:active,
|
||||
.refresh-btn:active {
|
||||
.fullscreen-btn:active,
|
||||
.refresh-btn:active,
|
||||
.settings-btn:active {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-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;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
display: grid;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,15 @@
|
|||
// 管理员API
|
||||
const ADMIN_BASE_URL = ''
|
||||
import { getBaseUrl } from '../utils/config'
|
||||
|
||||
let adminBaseUrlCache = ''
|
||||
|
||||
// 获取基础URL(带缓存)
|
||||
async function getCachedBaseUrl() {
|
||||
if (!adminBaseUrlCache) {
|
||||
adminBaseUrlCache = await getBaseUrl()
|
||||
}
|
||||
return adminBaseUrlCache
|
||||
}
|
||||
|
||||
function getAdminToken() {
|
||||
return localStorage.getItem('admin_token') || ''
|
||||
|
|
@ -7,8 +17,10 @@ function getAdminToken() {
|
|||
|
||||
async function request(url, options = {}) {
|
||||
const token = getAdminToken()
|
||||
const baseUrl = await getCachedBaseUrl()
|
||||
const fullUrl = baseUrl ? `${baseUrl}${url}` : url
|
||||
|
||||
const response = await fetch(`${ADMIN_BASE_URL}${url}`, {
|
||||
const response = await fetch(fullUrl, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -123,3 +135,10 @@ export async function adminSeek(time) {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 API 地址缓存(当API地址改变时调用)
|
||||
*/
|
||||
export function clearAdminBaseUrlCache() {
|
||||
adminBaseUrlCache = ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
// Midway后端API
|
||||
// 使用相对路径,通过Vite代理转发到后端
|
||||
const BASE_URL = ''
|
||||
import { getBaseUrl } from '../utils/config'
|
||||
|
||||
let baseUrlCache = ''
|
||||
|
||||
// 获取基础URL(带缓存)
|
||||
async function getCachedBaseUrl() {
|
||||
if (!baseUrlCache) {
|
||||
baseUrlCache = await getBaseUrl()
|
||||
}
|
||||
return baseUrlCache
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
// 使用相对路径,Vite会通过代理转发到后端
|
||||
// 获取基础URL
|
||||
const baseUrl = await getCachedBaseUrl()
|
||||
|
||||
// 构建完整URL
|
||||
const url = path.startsWith('/') ? path : `/${path}`
|
||||
const fullUrl = baseUrl ? `${baseUrl}${url}` : url
|
||||
const { method = 'GET', body, params = {} } = options
|
||||
|
||||
// 构建URL,添加查询参数
|
||||
let requestUrl = url
|
||||
let requestUrl = fullUrl
|
||||
if (method === 'GET' && params && Object.keys(params).length > 0) {
|
||||
const searchParams = new URLSearchParams()
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
|
|
@ -180,8 +193,8 @@ export async function getPlaybackState() {
|
|||
})
|
||||
}
|
||||
|
||||
export function getBaseUrl() {
|
||||
// 返回空字符串,使用相对路径通过代理
|
||||
return ''
|
||||
// 清除缓存(当API地址改变时调用)
|
||||
export function clearBaseUrlCache() {
|
||||
baseUrlCache = ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
// WebSocket连接管理
|
||||
// 使用相对路径,通过Vite代理转发到后端
|
||||
// 在开发环境下,Vite会代理WebSocket连接
|
||||
const WS_BASE_URL = import.meta.env.DEV
|
||||
? `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}`
|
||||
: 'ws://localhost:7001'
|
||||
import { getWsUrl } from '../utils/config'
|
||||
|
||||
let wsBaseUrlCache = ''
|
||||
|
||||
// 获取 WebSocket 基础URL(带缓存)
|
||||
async function getCachedWsBaseUrl() {
|
||||
if (!wsBaseUrlCache) {
|
||||
wsBaseUrlCache = await getWsUrl()
|
||||
}
|
||||
return wsBaseUrlCache
|
||||
}
|
||||
|
||||
let ws = null
|
||||
let reconnectTimer = null
|
||||
|
|
@ -27,15 +33,18 @@ function getClientId() {
|
|||
/**
|
||||
* 初始化WebSocket连接
|
||||
*/
|
||||
export function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId) {
|
||||
export async function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId) {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
console.log('WebSocket已经连接')
|
||||
return
|
||||
}
|
||||
|
||||
// 获取 WebSocket 基础URL
|
||||
const wsBaseUrl = await getCachedWsBaseUrl()
|
||||
|
||||
// 获取客户端唯一标识
|
||||
const clientId = getClientId()
|
||||
const wsUrl = `${WS_BASE_URL}/ws?clientId=${encodeURIComponent(clientId)}`
|
||||
const wsUrl = `${wsBaseUrl}/ws?clientId=${encodeURIComponent(clientId)}`
|
||||
console.log('正在连接WebSocket:', wsUrl)
|
||||
|
||||
try {
|
||||
|
|
@ -89,8 +98,8 @@ export function initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onA
|
|||
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++
|
||||
console.log(`尝试重连 (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId)
|
||||
reconnectTimer = setTimeout(async () => {
|
||||
await initWebSocket(onQueueUpdate, onPlaybackState, onOnlineCount, onAnnouncement, onVolumeControl, onUserId)
|
||||
}, RECONNECT_DELAY)
|
||||
} else {
|
||||
console.error('WebSocket重连失败,已达到最大重试次数')
|
||||
|
|
@ -137,3 +146,10 @@ export function getWebSocketStatus() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除 WebSocket URL 缓存(当API地址改变时调用)
|
||||
*/
|
||||
export function clearWsUrlCache() {
|
||||
wsBaseUrlCache = ''
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
import { defineProps, ref, watch, nextTick, toRefs, onMounted, onBeforeUnmount, computed } from 'vue'
|
||||
import { getLyric } from '../api/backend'
|
||||
import { parseLrc } from '../utils/lrc'
|
||||
import { getBaseUrl } from '../utils/config'
|
||||
|
||||
const props = defineProps({
|
||||
selectedSong: { type: Object, default: null },
|
||||
|
|
@ -88,9 +89,43 @@ const currentSong = computed(() => {
|
|||
})
|
||||
|
||||
// 流URL(后端代理)
|
||||
const streamUrl = computed(() => {
|
||||
if (!currentSong.value?.id) return null
|
||||
return `/api/music/stream/${currentSong.value.id}?level=standard`
|
||||
const streamUrl = ref(null)
|
||||
|
||||
// 更新流URL
|
||||
async function updateStreamUrl() {
|
||||
if (!currentSong.value?.id) {
|
||||
streamUrl.value = null
|
||||
return
|
||||
}
|
||||
|
||||
// 在 Electron 环境中,需要使用完整的 API URL
|
||||
if (window.electronAPI || window.location.protocol === 'file:') {
|
||||
try {
|
||||
const baseUrl = await getBaseUrl()
|
||||
streamUrl.value = `${baseUrl}/api/music/stream/${currentSong.value.id}?level=standard`
|
||||
} catch (error) {
|
||||
console.error('获取流URL失败:', error)
|
||||
streamUrl.value = null
|
||||
}
|
||||
} else {
|
||||
// 浏览器环境使用相对路径(通过 Vite 代理)
|
||||
streamUrl.value = `/api/music/stream/${currentSong.value.id}?level=standard`
|
||||
}
|
||||
}
|
||||
|
||||
// 监听当前歌曲变化,更新流URL
|
||||
watch(() => currentSong.value?.id, () => {
|
||||
updateStreamUrl()
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 streamUrl 变化,确保音频元素 src 更新
|
||||
watch(streamUrl, (newUrl) => {
|
||||
if (audioRef.value && newUrl) {
|
||||
console.log('更新音频源URL:', newUrl)
|
||||
// 强制重新加载音频
|
||||
audioRef.value.src = newUrl
|
||||
audioRef.value.load()
|
||||
}
|
||||
})
|
||||
|
||||
function formatDuration(sec) {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,36 @@ import { createApp } from 'vue'
|
|||
import App from './App.vue'
|
||||
import AdminLogin from './pages/AdminLogin.vue'
|
||||
import AdminPanel from './pages/AdminPanel.vue'
|
||||
import Settings from './pages/Settings.vue'
|
||||
import './style.css'
|
||||
|
||||
// 获取当前路径(支持 Electron 的 file:// 协议)
|
||||
function getCurrentPath() {
|
||||
// 在 Electron 的 file:// 协议下,pathname 可能总是返回 '/'
|
||||
// 优先使用 hash 路由(Electron 环境)
|
||||
if (window.electronAPI) {
|
||||
const hash = window.location.hash
|
||||
if (hash && hash.startsWith('#/')) {
|
||||
return hash.substring(1) // 去掉 # 号,返回如 '/settings'
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
// 浏览器环境:使用 pathname
|
||||
return window.location.pathname
|
||||
}
|
||||
|
||||
// 根据路径决定加载哪个组件
|
||||
const path = window.location.pathname
|
||||
function loadApp() {
|
||||
const path = getCurrentPath()
|
||||
|
||||
let app
|
||||
// 如果已经有 app 实例,先卸载
|
||||
if (window.currentApp) {
|
||||
window.currentApp.unmount()
|
||||
}
|
||||
|
||||
if (path.startsWith('/admin')) {
|
||||
let app
|
||||
|
||||
if (path.startsWith('/admin')) {
|
||||
if (path === '/admin/login') {
|
||||
// 管理员登录页面
|
||||
app = createApp(AdminLogin)
|
||||
|
|
@ -18,21 +40,43 @@ if (path.startsWith('/admin')) {
|
|||
// 检查token
|
||||
const token = localStorage.getItem('admin_token')
|
||||
if (!token) {
|
||||
if (window.electronAPI) {
|
||||
window.history.pushState({}, '', '/admin/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
app = createApp(AdminLogin)
|
||||
} else {
|
||||
app = createApp(AdminPanel)
|
||||
}
|
||||
} else {
|
||||
// 其他admin路径,默认跳转到登录页
|
||||
if (window.electronAPI) {
|
||||
window.history.pushState({}, '', '/admin/login')
|
||||
window.dispatchEvent(new PopStateEvent('popstate'))
|
||||
} else {
|
||||
window.location.href = '/admin/login'
|
||||
}
|
||||
app = createApp(AdminLogin)
|
||||
}
|
||||
} else {
|
||||
} else if (path === '/settings') {
|
||||
// 设置页面
|
||||
app = createApp(Settings)
|
||||
} else {
|
||||
// 主应用
|
||||
app = createApp(App)
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
window.currentApp = app
|
||||
}
|
||||
|
||||
app.mount('#app')
|
||||
// 监听路由变化(支持 Electron)
|
||||
window.addEventListener('popstate', loadApp)
|
||||
window.addEventListener('hashchange', loadApp)
|
||||
|
||||
// 初始加载
|
||||
loadApp()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -371,7 +371,9 @@ onMounted(() => {
|
|||
loadUsers()
|
||||
|
||||
// 初始化WebSocket(只监听播放状态)
|
||||
initWebSocket(() => {}, onPlaybackStateUpdate, () => {}, () => {}, () => {}, () => {})
|
||||
initWebSocket(() => {}, onPlaybackStateUpdate, () => {}, () => {}, () => {}, () => {}).catch(err => {
|
||||
console.error('WebSocket初始化失败:', err)
|
||||
})
|
||||
|
||||
// 定期刷新播放状态和用户列表
|
||||
const interval = setInterval(() => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,387 @@
|
|||
<template>
|
||||
<div class="settings-page">
|
||||
<header class="settings-header">
|
||||
<h1>设置</h1>
|
||||
<button class="btn-back" @click="handleBack">返回</button>
|
||||
</header>
|
||||
|
||||
<main class="settings-main">
|
||||
<section class="settings-section">
|
||||
<h2>后端服务器配置</h2>
|
||||
<div class="form-group">
|
||||
<label>API 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="apiUrl"
|
||||
placeholder="http://localhost:7001"
|
||||
@input="validateUrl"
|
||||
/>
|
||||
<p class="help-text">请输入后端服务器的完整地址(包含 http:// 或 https://)</p>
|
||||
<p v-if="urlError" class="error-text">{{ urlError }}</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>WebSocket 地址</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="wsUrl"
|
||||
placeholder="ws://localhost:7001"
|
||||
readonly
|
||||
/>
|
||||
<p class="help-text">WebSocket 地址会根据 API 地址自动生成</p>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button class="btn-save" @click="handleSave" :disabled="loading || !!urlError">
|
||||
{{ loading ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
<button class="btn-test" @click="handleTest" :disabled="loading || !!urlError">
|
||||
测试连接
|
||||
</button>
|
||||
<button class="btn-reset" @click="handleReset">
|
||||
重置为默认值
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="test-result" :class="{ success: testResult.success, error: !testResult.success }">
|
||||
<p>{{ testResult.message }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { setApiUrl, getApiUrl, getWsUrl } from '../utils/config'
|
||||
import { clearBaseUrlCache } from '../api/backend'
|
||||
import { closeWebSocket, clearWsUrlCache } from '../api/websocket'
|
||||
import { clearAdminBaseUrlCache } from '../api/admin'
|
||||
|
||||
const apiUrl = ref('http://localhost:7001')
|
||||
const wsUrl = ref('ws://localhost:7001')
|
||||
const urlError = ref('')
|
||||
const loading = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
// 加载当前配置
|
||||
async function loadSettings() {
|
||||
try {
|
||||
const savedApiUrl = await getApiUrl()
|
||||
apiUrl.value = savedApiUrl
|
||||
updateWsUrl()
|
||||
} catch (error) {
|
||||
console.error('加载设置失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 更新 WebSocket URL
|
||||
function updateWsUrl() {
|
||||
wsUrl.value = apiUrl.value.replace(/^http/, 'ws')
|
||||
}
|
||||
|
||||
// 验证 URL 格式
|
||||
function validateUrl() {
|
||||
urlError.value = ''
|
||||
testResult.value = null
|
||||
|
||||
if (!apiUrl.value) {
|
||||
urlError.value = 'API 地址不能为空'
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(apiUrl.value)
|
||||
// 验证协议
|
||||
if (!apiUrl.value.startsWith('http://') && !apiUrl.value.startsWith('https://')) {
|
||||
urlError.value = 'URL 必须以 http:// 或 https:// 开头'
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
urlError.value = '无效的 URL 格式'
|
||||
return false
|
||||
}
|
||||
|
||||
updateWsUrl()
|
||||
return true
|
||||
}
|
||||
|
||||
// 监听 API URL 变化
|
||||
watch(apiUrl, () => {
|
||||
validateUrl()
|
||||
})
|
||||
|
||||
// 保存设置
|
||||
async function handleSave() {
|
||||
if (!validateUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
await setApiUrl(apiUrl.value)
|
||||
// 清除所有缓存
|
||||
clearBaseUrlCache()
|
||||
clearWsUrlCache()
|
||||
clearAdminBaseUrlCache()
|
||||
// 关闭现有连接,让应用重新连接
|
||||
closeWebSocket()
|
||||
|
||||
testResult.value = {
|
||||
success: true,
|
||||
message: '设置已保存!请刷新页面以应用新设置。'
|
||||
}
|
||||
|
||||
// 提示刷新
|
||||
setTimeout(() => {
|
||||
if (confirm('设置已保存,是否立即刷新页面?')) {
|
||||
window.location.reload()
|
||||
}
|
||||
}, 500)
|
||||
} catch (error) {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: '保存失败: ' + error.message
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
async function handleTest() {
|
||||
if (!validateUrl()) {
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
// 测试 API 连接
|
||||
const testUrl = `${apiUrl.value}/api/music/queue`
|
||||
const response = await fetch(testUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (response.ok) {
|
||||
testResult.value = {
|
||||
success: true,
|
||||
message: '连接成功!服务器可以正常访问。'
|
||||
}
|
||||
} else {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: `连接失败: HTTP ${response.status}`
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: '连接失败: ' + (error.message || '无法连接到服务器')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置为默认值
|
||||
function handleReset() {
|
||||
apiUrl.value = 'http://192.168.1.148:7001'
|
||||
updateWsUrl()
|
||||
urlError.value = ''
|
||||
testResult.value = null
|
||||
}
|
||||
|
||||
// 返回
|
||||
function handleBack() {
|
||||
// 在 Electron 中使用 hash 路由返回
|
||||
if (window.electronAPI) {
|
||||
window.location.hash = ''
|
||||
} else {
|
||||
window.history.back()
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-header h1 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 16px;
|
||||
background: #64748b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: #475569;
|
||||
}
|
||||
|
||||
.settings-main {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.settings-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.form-group input[readonly] {
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
margin: 6px 0 0 0;
|
||||
font-size: 12px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-save,
|
||||
.btn-test,
|
||||
.btn-reset {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.btn-save {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-save:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-save:disabled {
|
||||
background: #93c5fd;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-test {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-test:hover:not(:disabled) {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.btn-test:disabled {
|
||||
background: #6ee7b7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-reset {
|
||||
background: #e5e7eb;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.btn-reset:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.test-result {
|
||||
margin-top: 16px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.test-result.success {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
border: 1px solid #10b981;
|
||||
}
|
||||
|
||||
.test-result.error {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
border: 1px solid #ef4444;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// 配置管理工具
|
||||
// 支持从 localStorage 或 Electron 获取 API 地址
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:7001';
|
||||
const DEFAULT_WS_URL = 'ws://localhost:7001';
|
||||
|
||||
/**
|
||||
* 获取 API 地址
|
||||
*/
|
||||
export async function getApiUrl() {
|
||||
// 检查是否在 Electron 环境中
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
return await window.electronAPI.getApiUrl();
|
||||
} catch (error) {
|
||||
console.error('获取 Electron API 地址失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 localStorage 获取
|
||||
const saved = localStorage.getItem('api_url');
|
||||
if (saved) {
|
||||
return saved;
|
||||
}
|
||||
|
||||
// 开发环境使用默认值
|
||||
if (import.meta.env.DEV) {
|
||||
return DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
// 生产环境(Electron 或 Web):尝试从当前域名推断,否则使用默认值
|
||||
// 在 Electron 中,如果使用 file:// 协议,则使用默认值
|
||||
if (window.location.protocol === 'file:') {
|
||||
return DEFAULT_API_URL;
|
||||
}
|
||||
|
||||
const protocol = window.location.protocol === 'https:' ? 'https:' : 'http:';
|
||||
const host = window.location.host;
|
||||
return `${protocol}//${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 API 地址
|
||||
*/
|
||||
export async function setApiUrl(url) {
|
||||
// 验证 URL 格式
|
||||
try {
|
||||
new URL(url);
|
||||
} catch (error) {
|
||||
throw new Error('无效的 URL 格式');
|
||||
}
|
||||
|
||||
// 检查是否在 Electron 环境中
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
await window.electronAPI.setApiUrl(url);
|
||||
return;
|
||||
} catch (error) {
|
||||
console.error('设置 Electron API 地址失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存到 localStorage
|
||||
localStorage.setItem('api_url', url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 WebSocket 地址
|
||||
*/
|
||||
export async function getWsUrl() {
|
||||
// 检查是否在 Electron 环境中
|
||||
if (window.electronAPI) {
|
||||
try {
|
||||
return await window.electronAPI.getWsUrl();
|
||||
} catch (error) {
|
||||
console.error('获取 Electron WebSocket 地址失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 从 localStorage 获取 API 地址并转换为 WebSocket 地址
|
||||
const apiUrl = await getApiUrl();
|
||||
return apiUrl.replace(/^http/, 'ws');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础 URL(用于 API 请求)
|
||||
*/
|
||||
export async function getBaseUrl() {
|
||||
const apiUrl = await getApiUrl();
|
||||
|
||||
// 如果在 Electron 或生产环境中,直接使用 API 地址
|
||||
if (window.electronAPI || !import.meta.env.DEV) {
|
||||
return apiUrl;
|
||||
}
|
||||
|
||||
// 开发环境:使用相对路径,通过 Vite 代理
|
||||
return '';
|
||||
}
|
||||
|
||||
|
|
@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
|
|||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
base: './', // 使用相对路径,便于 Electron 打包
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true,
|
||||
|
|
@ -21,8 +22,15 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// 确保所有路由都返回 index.html(SPA 路由支持)
|
||||
// Vite 开发服务器默认支持 history API fallback
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: './index.html',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
2088
webapp/yarn.lock
2088
webapp/yarn.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -29,7 +29,7 @@ export class AdminController {
|
|||
return false;
|
||||
}
|
||||
|
||||
const adminToken = process.env.ADMIN_TOKEN || 'admin123';
|
||||
const adminToken = process.env.ADMIN_TOKEN || 'admin1a3sd21as5d3asd21w3a524sd1w3a52s1da23';
|
||||
|
||||
if (token !== adminToken) {
|
||||
this.ctx.status = 403;
|
||||
|
|
@ -48,7 +48,7 @@ export class AdminController {
|
|||
*/
|
||||
@Post('/login')
|
||||
async login(@Body() body: { password: string }) {
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'admin';
|
||||
const adminPassword = process.env.ADMIN_PASSWORD || 'adminYeeywwaea1sd23w1a32sd1was53dw';
|
||||
|
||||
if (body.password !== adminPassword) {
|
||||
return {
|
||||
|
|
@ -59,7 +59,7 @@ export class AdminController {
|
|||
}
|
||||
|
||||
// 返回token(实际生产环境应使用JWT等更安全的方式)
|
||||
const token = process.env.ADMIN_TOKEN || 'admin123';
|
||||
const token = process.env.ADMIN_TOKEN || 'admin1a3sd21as5d3asd21w3a524sd1w3a52s1da23';
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export class AdminMiddleware implements IMiddleware<Context, NextFunction> {
|
|||
|
||||
// 简单的token验证(实际生产环境应使用更安全的方式)
|
||||
// 这里使用配置中的admin token
|
||||
const adminToken = process.env.ADMIN_TOKEN || 'admin123';
|
||||
const adminToken = process.env.ADMIN_TOKEN || 'admin1a3sd21as5d3asd21w3a524sd1w3a52s1da23';
|
||||
|
||||
if (token !== adminToken) {
|
||||
ctx.status = 403;
|
||||
|
|
|
|||
Loading…
Reference in New Issue